Skip to content
Merged
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
56 changes: 51 additions & 5 deletions src/session/__tests__/fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ vi.mock('../../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

const { sendTextMock, replyInThreadMock, getThreadSessionMock, createForkedThreadSessionMock } = vi.hoisted(() => ({
const { sendTextMock, replyInThreadMock, getThreadSessionMock, createForkedThreadSessionMock, forkClaudeSessionMock } = vi.hoisted(() => ({
sendTextMock: vi.fn(),
replyInThreadMock: vi.fn(),
getThreadSessionMock: vi.fn(),
createForkedThreadSessionMock: vi.fn(),
forkClaudeSessionMock: vi.fn(),
}));
vi.mock('../../feishu/client.js', () => ({
feishuClient: {
Expand All @@ -27,6 +28,9 @@ vi.mock('../manager.js', () => ({
createForkedThreadSession: createForkedThreadSessionMock,
},
}));
vi.mock('../claude-session-fork.js', () => ({
forkClaudeSession: forkClaudeSessionMock,
}));

// config 里只用到 claude.defaultWorkDir,vi.mock 会被 hoist 到顶部,
// 因此用 vi.hoisted 让临时目录在 mock 之前初始化。
Expand All @@ -53,6 +57,7 @@ const CHAT_ID = 'oc_chat_test';
const USER_ID = 'ou_user_test';
const PARENT_THREAD_ID = 'omt_parent_thread';
const TRIGGER_MSG_ID = 'om_trigger';
let forkCounter = 0;

function makeParentSession(overrides: { workingDir?: string; conversationId?: string | null; conversationCwd?: string | null; approved?: boolean } = {}) {
return {
Expand All @@ -77,6 +82,16 @@ function seedParentJsonl(): string {
return path;
}

function mockOfficialForkJsonl() {
forkClaudeSessionMock.mockImplementation(async ({ sourceCwd }: { sourceCwd: string }) => {
const id = `fork-conv-id-${++forkCounter}`;
const path = resolveSessionJsonlPath(sourceCwd, id);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, `{"sessionId":"${id}","type":"summary","summary":"official fork"}\n`);
return id;
});
}

describe('forkSession', () => {
let parentJsonl: string;

Expand All @@ -85,6 +100,9 @@ describe('forkSession', () => {
replyInThreadMock.mockReset();
getThreadSessionMock.mockReset();
createForkedThreadSessionMock.mockReset();
forkClaudeSessionMock.mockReset();
forkCounter = 0;
mockOfficialForkJsonl();
parentJsonl = seedParentJsonl();
});

Expand All @@ -94,7 +112,7 @@ describe('forkSession', () => {
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
});

it('成功路径:复制 JSONL、创建话题、写入血缘字段', async () => {
it('成功路径:官方 fork JSONL、创建话题、写入血缘字段', async () => {
getThreadSessionMock.mockReturnValue(makeParentSession());
sendTextMock.mockResolvedValue('om_new_root');
replyInThreadMock.mockResolvedValue({ messageId: 'om_reply', threadId: 'omt_new_thread' });
Expand All @@ -114,7 +132,12 @@ describe('forkSession', () => {
expect(result.shortId).toMatch(/^[0-9a-f]{4}$/);
expect(result.workingDir).toBe(TEST_DEFAULT_DIR);

// 新 JSONL 应已落盘
// SDK fork 应使用父 session/cwd,新 JSONL 应已落盘
expect(forkClaudeSessionMock).toHaveBeenCalledWith({
parentSessionId: PARENT_CONV_ID,
sourceCwd: TEST_DEFAULT_DIR,
title: expect.stringContaining(result.shortId),
});
const newJsonl = resolveSessionJsonlPath(TEST_DEFAULT_DIR, result.newConversationId);
expect(existsSync(newJsonl)).toBe(true);

Expand Down Expand Up @@ -165,9 +188,25 @@ describe('forkSession', () => {
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('parent_jsonl_missing');
expect(forkClaudeSessionMock).not.toHaveBeenCalled();
expect(sendTextMock).not.toHaveBeenCalled();
});

it('session_fork_failed:官方 fork 失败时不创建飞书话题', async () => {
getThreadSessionMock.mockReturnValue(makeParentSession());
forkClaudeSessionMock.mockRejectedValue(new Error('invalid transcript'));

const result = await forkSession({
parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('session_fork_failed');
expect(result.message).toContain('invalid transcript');
expect(sendTextMock).not.toHaveBeenCalled();
expect(createForkedThreadSessionMock).not.toHaveBeenCalled();
});

it('feishu_thread_create_failed:sendText 返回 undefined,JSONL 应回滚', async () => {
getThreadSessionMock.mockReturnValue(makeParentSession());
sendTextMock.mockResolvedValue(undefined);
Expand Down Expand Up @@ -292,6 +331,9 @@ describe('forkSession - 场景 A (worktree + WIP)', () => {
replyInThreadMock.mockReset();
getThreadSessionMock.mockReset();
createForkedThreadSessionMock.mockReset();
forkClaudeSessionMock.mockReset();
forkCounter = 0;
mockOfficialForkJsonl();
sendTextMock.mockResolvedValue('om_new_root');
replyInThreadMock.mockResolvedValue({ messageId: 'om_reply', threadId: 'omt_new_thread' });

Expand Down Expand Up @@ -351,8 +393,12 @@ describe('forkSession - 场景 A (worktree + WIP)', () => {
{ cwd: parentWorkdir, encoding: 'utf8' });
expect(parentStatusAfter).toBe(parentStatusBefore);

// DB 写入的 workingDir 是新 worktree 路径
expect(createForkedThreadSessionMock.mock.calls[0][0].workingDir).toBe(result.workingDir);
// DB 写入的 workingDir/conversationCwd 是新 worktree 路径,JSONL 已移动到新 project 目录
const dbArgs = createForkedThreadSessionMock.mock.calls[0][0];
expect(dbArgs.workingDir).toBe(result.workingDir);
expect(dbArgs.conversationCwd).toBe(result.workingDir);
expect(existsSync(resolveSessionJsonlPath(result.workingDir, result.newConversationId))).toBe(true);
expect(existsSync(resolveSessionJsonlPath(parentWorkdir, result.newConversationId))).toBe(false);
});

it('--clean 跳过 WIP 继承:子 worktree 是纯 HEAD,无未提交改动', async () => {
Expand Down
33 changes: 33 additions & 0 deletions src/session/__tests__/jsonl-fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
encodeProjectDir,
resolveSessionJsonlPath,
copyJsonlAtomic,
moveJsonlAtomic,
jsonlFingerprint,
} from '../jsonl-fork.js';

Expand Down Expand Up @@ -81,6 +82,38 @@ describe('copyJsonlAtomic', () => {
});
});

describe('moveJsonlAtomic', () => {
let workDir: string;

beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'jsonl-move-test-'));
});

afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});

it('moves through a temp file in the target directory', () => {
const src = join(workDir, 'src.jsonl');
const dst = join(workDir, 'target', 'dst.jsonl');
writeFileSync(src, 'official fork');

moveJsonlAtomic(src, dst);

expect(existsSync(src)).toBe(false);
expect(readFileSync(dst, 'utf8')).toBe('official fork');
});

it('is a no-op when source and target are the same file', () => {
const src = join(workDir, 'same.jsonl');
writeFileSync(src, 'same');

moveJsonlAtomic(src, src);

expect(readFileSync(src, 'utf8')).toBe('same');
});
});

describe('jsonlFingerprint', () => {
it('returns undefined for missing file', () => {
expect(jsonlFingerprint('/nonexistent/path.jsonl')).toBeUndefined();
Expand Down
19 changes: 19 additions & 0 deletions src/session/claude-session-fork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { forkSession as forkClaudeCodeSession } from '@anthropic-ai/claude-agent-sdk';

export interface ForkClaudeSessionOptions {
parentSessionId: string;
sourceCwd: string;
title?: string;
}

/**
* 用官方 Claude Code session fork 语义生成新的 transcript。
* 调用方仍负责把生成的 JSONL 移到目标 worktree 对应的 project 目录。
*/
export async function forkClaudeSession(opts: ForkClaudeSessionOptions): Promise<string> {
const result = await forkClaudeCodeSession(opts.parentSessionId, {
dir: opts.sourceCwd,
title: opts.title,
});
return result.sessionId;
}
42 changes: 31 additions & 11 deletions src/session/fork.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { unlinkSync } from 'node:fs';
import { resolve } from 'node:path';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';
import { feishuClient } from '../feishu/client.js';
import { sessionManager } from './manager.js';
import { copyJsonlAtomic, resolveSessionJsonlPath, jsonlFingerprint } from './jsonl-fork.js';
import { resolveSessionJsonlPath, jsonlFingerprint, moveJsonlAtomic } from './jsonl-fork.js';
import { forkClaudeSession } from './claude-session-fork.js';

/** Fork 失败的原因码(供命令层做差异化提示) */
export type ForkFailureReason =
| 'no_parent_thread'
| 'parent_not_found'
| 'no_conversation'
| 'parent_jsonl_missing'
| 'session_fork_failed'
| 'feishu_thread_create_failed'
| 'worktree_create_failed'
| 'stash_apply_failed'
Expand Down Expand Up @@ -99,16 +101,16 @@ export async function forkSession(opts: ForkOptions): Promise<ForkResult | ForkE
}

const shortId = generateShortId();
const newConversationId = randomUUID();
const rootTitle = buildRootTitle(opts.description, shortId);
// 场景 A 的子 conversationCwd 必须用新 worktree 路径(否则 SDK resume 时 cwd 不匹配)
const newWorkdir = isScenarioA ? `${parent.workingDir}-fork-${shortId}` : parent.workingDir;
const newConversationCwd = isScenarioA ? newWorkdir : parent.conversationCwd;
const newJsonl = resolveSessionJsonlPath(newConversationCwd, newConversationId);

// 回滚追踪标志
let worktreeCreated = false;
let newBranchName: string | undefined;
let newJsonlCreated = false;
let newConversationId = '';
let sdkForkJsonl: string | undefined;
let newJsonl: string | undefined;
let rootMessageId: string | undefined;

try {
Expand Down Expand Up @@ -174,14 +176,29 @@ export async function forkSession(opts: ForkOptions): Promise<ForkResult | ForkE
}
}

// ── 通用流程: JSONL → Feishu → DB ──
copyJsonlAtomic(parentJsonl, newJsonl);
newJsonlCreated = true;
// ── 通用流程: 官方 transcript fork → Feishu → DB ──
try {
newConversationId = await forkClaudeSession({
parentSessionId: parent.conversationId,
sourceCwd: parent.conversationCwd,
title: rootTitle,
});
if (!newConversationId) {
throw new Error('Claude SDK did not return a forked sessionId');
}
sdkForkJsonl = resolveSessionJsonlPath(parent.conversationCwd, newConversationId);
newJsonl = resolveSessionJsonlPath(newConversationCwd, newConversationId);
moveJsonlAtomic(sdkForkJsonl, newJsonl);
} catch (err) {
throw new ForkAbort(
'session_fork_failed',
`创建 Claude 会话 fork 失败: ${(err as Error).message}`,
);
}

// 在父话题里贴一条 "🔱 …" 的 top-level 消息作为新话题根。
// 注意:reply_in_thread=true 在父消息已属于话题时会留在原话题里,所以这里直接用 sendText
// 发到主聊天区,得到一条非话题消息;再在其上 replyInThread 形成新话题。
const rootTitle = buildRootTitle(opts.description, shortId);
rootMessageId = await feishuClient.sendText(opts.chatId, rootTitle);
if (!rootMessageId) {
throw new ForkAbort('feishu_thread_create_failed', '创建新话题根消息失败');
Expand Down Expand Up @@ -243,9 +260,12 @@ export async function forkSession(opts: ForkOptions): Promise<ForkResult | ForkE
// 父工作树状态:正常路径 push+pop 已成对完成,无需回滚;
// 若进入 CRITICAL 分支(子 apply 成功、父 pop 失败),父 WIP 仍卡在 stash@{0},
// 由上方 logger.error 提示用户手动 `git stash pop`,此处不再尝试。
if (newJsonlCreated) {
if (newJsonl) {
try { unlinkSync(newJsonl); } catch { /* ignore */ }
}
if (sdkForkJsonl && sdkForkJsonl !== newJsonl) {
try { unlinkSync(sdkForkJsonl); } catch { /* ignore */ }
}
if (worktreeCreated) {
try {
gitExec(parent.workingDir, ['worktree', 'remove', '--force', newWorkdir]);
Expand Down
17 changes: 17 additions & 0 deletions src/session/jsonl-fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ export function copyJsonlAtomic(srcPath: string, dstPath: string): void {
}
}

/**
* 把 SDK 生成的 fork JSONL 放到目标 cwd 对应的 project 目录。
*
* src/dst 往往位于 ~/.claude/projects 下的不同子目录。为了避免跨设备 rename 失败,
* 这里先 copy 到目标目录临时文件,再 rename 成目标文件,最后删除源文件。
*/
export function moveJsonlAtomic(srcPath: string, dstPath: string): void {
if (srcPath === dstPath) {
if (!existsSync(srcPath)) {
throw new Error(`source JSONL not found: ${srcPath}`);
}
return;
}
copyJsonlAtomic(srcPath, dstPath);
unlinkSync(srcPath);
}

/** 计算 JSONL 末尾签名(size + mtime),P2 fork_point 字段用。 */
export function jsonlFingerprint(jsonlPath: string): string | undefined {
if (!existsSync(jsonlPath)) return undefined;
Expand Down
Loading