From f1274fc22abe833a15263d0e85be8ca2a5550020 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 20:46:45 +0800 Subject: [PATCH] feat(agent-core-v2): read and write the v1 session index file - add a legacySessionIndex module defining the v1 session_index.jsonl byte format: tolerant line parsing and v1's entry validation (absolute sessionDir inside sessionsDir, basename matches sessionId), shared by the v2 readers and writer - append a v1-compatible index line on session create/fork (never on resume) through the append-log store, so a v1 CLI sharing the homeDir discovers v2 sessions - locate sessions through the legacy index in FileSessionIndex.get (and read-model cold misses) with a directory-scan fallback for stale lines - reuse the shared parser in the workspace registry's one-shot rebuild --- .changeset/v2-legacy-session-index.md | 5 + .../app/sessionIndex/legacySessionIndex.ts | 74 +++++++++++++ .../app/sessionIndex/sessionIndexService.ts | 53 ++++++++- .../sessionLifecycleService.ts | 28 ++++- .../workspaceRegistryService.ts | 42 ++------ .../app/sessionIndex/sessionIndex.test.ts | 102 ++++++++++++++++++ .../sessionLifecycle/sessionLifecycle.test.ts | 92 ++++++++++++++++ 7 files changed, 359 insertions(+), 37 deletions(-) create mode 100644 .changeset/v2-legacy-session-index.md create mode 100644 packages/agent-core-v2/src/app/sessionIndex/legacySessionIndex.ts diff --git a/.changeset/v2-legacy-session-index.md b/.changeset/v2-legacy-session-index.md new file mode 100644 index 000000000..5e9620520 --- /dev/null +++ b/.changeset/v2-legacy-session-index.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Write v1-compatible session index entries when the v2 engine creates or forks sessions, so both engine generations discover each other's sessions on the same data directory. diff --git a/packages/agent-core-v2/src/app/sessionIndex/legacySessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/legacySessionIndex.ts new file mode 100644 index 000000000..d6863e8e3 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/legacySessionIndex.ts @@ -0,0 +1,74 @@ +/** + * `sessionIndex` domain (L2) — v1 legacy session index file format. + * + * Single definition of the byte format of `/session_index.jsonl`, + * shared by the v2 writer (`sessionLifecycle` appends one line per + * create/fork) and the v2 readers (`FileSessionIndex` point lookups, + * `workspaceRegistry` one-shot rebuild). The file is a v1 interop artifact: + * v2's own source of truth is the `///` + * directory tree; this file exists so a v1 CLI sharing the same homeDir can + * discover v2-created sessions, and so v2 can locate sessions the v1 way. + * + * Format (identical to v1 `packages/agent-core/src/session/store/session-index.ts`): + * append-only JSONL, one `{sessionId, sessionDir, workDir}` object per line; + * later lines override earlier ones for the same sessionId; `workDir` is + * informational only and never authoritative on read. + */ + +import { basename, dirname, isAbsolute, relative, resolve } from 'pathe'; + +/** Scope of the legacy index file: the homeDir root (join skips empty segments). */ +export const LEGACY_SESSION_INDEX_SCOPE = ''; +export const LEGACY_SESSION_INDEX_KEY = 'session_index.jsonl'; + +export interface LegacySessionIndexEntry { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +/** + * Tolerantly parse one index line. Returns `undefined` for blank-ish garbage, + * non-JSON text, and entries with non-string fields — same acceptance as v1's + * `parseIndexLine`, so a corrupt line never breaks a whole read. + */ +export function parseLegacySessionIndexLine(line: string): LegacySessionIndexEntry | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} + +/** + * Validate a parsed entry against v1's read-side rules and derive the + * workspaceId from the sessionDir layout. Returns `undefined` for entries v1 + * would skip: a non-absolute `sessionDir`, one outside `sessionsDir`, or one + * whose basename does not match `sessionId`. The workspaceId is the name of + * the bucket directory the session lives in (`//`). + */ +export function validateLegacySessionIndexEntry( + entry: LegacySessionIndexEntry, + sessionsDir: string, +): { sessionDir: string; workspaceId: string } | undefined { + if (!isAbsolute(entry.sessionDir)) return undefined; + const sessionDir = resolve(entry.sessionDir); + const rel = relative(resolve(sessionsDir), sessionDir); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return undefined; + if (basename(sessionDir) !== entry.sessionId) return undefined; + return { sessionDir, workspaceId: basename(dirname(sessionDir)) }; +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 00ac2f7e0..acf752d92 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -7,6 +7,12 @@ * session ids are enumerated via `IFileSystemStorageService.list`, and each session's * metadata document is read via `IAtomicDocumentStore` to build its summary. * + * Point lookups (`get`) first consult v1's legacy `/session_index.jsonl` + * (format defined in `./legacySessionIndex`) to locate a session's workspace in + * O(1), falling back to the directory scan when the file has no line for the id + * or the line is stale. Listings never trust the file: the tree stays + * authoritative. + * * The session metadata document lives at `/state.json`, a layout * shared by v1 and v2; the `version` field distinguishes them (`2` = v2, * epoch-ms timestamps; absent = v1, ISO-string timestamps). The reader also @@ -48,12 +54,20 @@ import { type SessionListQuery, type SessionSummary, } from './sessionIndex'; +import { + LEGACY_SESSION_INDEX_KEY, + LEGACY_SESSION_INDEX_SCOPE, + parseLegacySessionIndexLine, + validateLegacySessionIndexEntry, +} from './legacySessionIndex'; const META_SCOPE = 'session-meta'; const META_KEY = 'state.json'; const SESSION_COLLECTION = 'session'; const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; +const textDecoder = new TextDecoder(); + /** Accept both v2 (epoch ms number) and v1 (ISO string) timestamps. */ function parseTime(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; @@ -194,7 +208,14 @@ export class FileSessionIndex implements ISessionIndex { private async getFromReadModel(id: string): Promise { const cached = await this.queryStore.get(SESSION_COLLECTION, id); if (cached !== undefined) return cached; - // Cold miss: locate the session on disk, then read + backfill. + // Cold miss: locate the session on disk, then read + backfill. Prefer the + // legacy index (O(1)) over the per-workspace scan; a stale line falls + // through to the scan. + const located = (await this.readLegacyIndex()).get(id); + if (located !== undefined) { + const summary = await this.getCachedSummary(located, id); + if (summary !== undefined) return summary; + } for (const workspaceId of await this.listWorkspaceIds()) { if (!(await this.hasSession(workspaceId, id))) continue; return this.getCachedSummary(workspaceId, id); @@ -275,6 +296,13 @@ export class FileSessionIndex implements ISessionIndex { } private async getLegacy(id: string): Promise { + // Prefer the legacy index (O(1)) over the per-workspace scan; a stale line + // (deleted dir / unreadable metadata) falls through to the scan. + const located = (await this.readLegacyIndex()).get(id); + if (located !== undefined) { + const summary = await this.readSummary(located, id); + if (summary !== undefined) return summary; + } for (const workspaceId of await this.listWorkspaceIds()) { if (!(await this.hasSession(workspaceId, id))) continue; const summary = await this.readSummary(workspaceId, id); @@ -283,6 +311,29 @@ export class FileSessionIndex implements ISessionIndex { return undefined; } + /** + * Read v1's global session index (`/session_index.jsonl`) as a + * sessionId → workspaceId locator. Entries are validated with v1's rules + * (absolute sessionDir inside `sessionsDir`, basename === sessionId) and + * later lines override earlier ones. Returns an empty map when the file is + * missing or unreadable. + */ + private async readLegacyIndex(): Promise> { + const result = new Map(); + const bytes = await this.storage.read(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY); + if (bytes === undefined) return result; + for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseLegacySessionIndexLine(trimmed); + if (entry === undefined) continue; + const located = validateLegacySessionIndexEntry(entry, this.bootstrap.sessionsDir); + if (located === undefined) continue; + result.set(entry.sessionId, located.workspaceId); + } + return result; + } + private async countActiveLegacy(workspaceId: string): Promise { let count = 0; for (const sessionId of await this.listSessionIds(workspaceId)) { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 79f178ee3..947a079c0 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -47,6 +47,10 @@ import { ISessionIndex, PARENT_SESSION_ID_KEY, } from '#/app/sessionIndex/sessionIndex'; +import { + LEGACY_SESSION_INDEX_KEY, + LEGACY_SESSION_INDEX_SCOPE, +} from '#/app/sessionIndex/legacySessionIndex'; import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -82,6 +86,12 @@ import { type MaterializeSessionOptions = Omit & { readonly sessionId: string; readonly workspaceId?: string; + /** + * Append a v1-compatible `session_index.jsonl` line once the session is + * materialized. Set for brand-new sessions (create / fork target); resume + * must not re-append (v1 only writes on create/fork). + */ + readonly legacyIndex?: boolean; }; export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { @@ -126,7 +136,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); - const handle = await this.materializeSession({ ...opts, sessionId }); + const handle = await this.materializeSession({ ...opts, sessionId, legacyIndex: true }); await this.appendSessionIndexEntry(sessionId, opts.workDir); if (this.config.get(DEFAULT_PLAN_MODE_SECTION) === true) { const main = await ensureMainAgent(handle); @@ -193,6 +203,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec void handle.accessor.get(ISessionSkillCatalog).ready; await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(); handle.accessor.get(ISessionExternalHooksService); + if (opts.legacyIndex === true) { + // Project the new session into v1's global index so a v1 CLI sharing + // this homeDir can discover it. `AppendLogStore` framing is + // byte-identical to v1's `JSON.stringify(entry) + '\n'`; the explicit + // flush makes the line durable before create/fork returns. Placed at + // the materialization point (not fork completion) because v2 does not + // roll back a failed fork's directory — the tree is the index, so the + // file line matches on-disk reality. + this.appendLogStore.append(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY, { + sessionId: opts.sessionId, + sessionDir, + workDir: opts.workDir, + }); + await this.appendLogStore.flush(); + } return handle; } @@ -389,6 +414,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const target = await this.materializeSession({ sessionId: targetId, workDir: workspace.root, + legacyIndex: true, }); const targetCtx = target.accessor.get(ISessionContext); const targetMeta = target.accessor.get(ISessionMetadata); diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 53852f8d1..11d709e88 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -22,6 +22,11 @@ import { basename, isAbsolute } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { + LEGACY_SESSION_INDEX_KEY, + LEGACY_SESSION_INDEX_SCOPE, + parseLegacySessionIndexLine, +} from '#/app/sessionIndex/legacySessionIndex'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -29,19 +34,8 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; import { IWorkspacePersistence } from './workspacePersistence'; -// Legacy v1 session index, read only for the one-shot rebuild. Empty scope -// resolves to `/` (join skips empty segments). -const SESSION_INDEX_SCOPE = ''; -const SESSION_INDEX_KEY = 'session_index.jsonl'; - const textDecoder = new TextDecoder(); -interface SessionIndexLine { - readonly sessionId: string; - readonly sessionDir: string; - readonly workDir: string; -} - export class WorkspaceRegistryService implements IWorkspaceRegistry { declare readonly _serviceBrand: undefined; @@ -146,13 +140,13 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { private async rebuildFromSessionIndex(): Promise> { const result = new Map(); - const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); + const bytes = await this.storage.read(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY); if (bytes === undefined) return result; const now = Date.now(); for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed === '') continue; - const entry = parseSessionIndexLine(trimmed); + const entry = parseLegacySessionIndexLine(trimmed); if (entry === undefined) continue; if (!isAbsolute(entry.workDir)) continue; const id = encodeWorkDirKey(entry.workDir); @@ -178,28 +172,6 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { } } -function parseSessionIndexLine(line: string): SessionIndexLine | undefined { - try { - const parsed = JSON.parse(line) as unknown; - if (typeof parsed !== 'object' || parsed === null) return undefined; - const entry = parsed as Partial; - if ( - typeof entry.sessionId !== 'string' || - typeof entry.sessionDir !== 'string' || - typeof entry.workDir !== 'string' - ) { - return undefined; - } - return { - sessionId: entry.sessionId, - sessionDir: entry.sessionDir, - workDir: entry.workDir, - }; - } catch { - return undefined; - } -} - /** * Collapse registered workspaces that share a `root`. The persisted catalog * (v1-compatible `workspaces.json`) can hold legacy entries whose id was diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index cd2bc1c71..236fe71da 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -90,6 +90,13 @@ describe('FileSessionIndex (legacy)', () => { await fsp.mkdir(join(sessionsDir, wsId, sessionId), { recursive: true }); } + async function seedIndexLine( + entry: { sessionId: string; sessionDir: string; workDir: string } | string, + ): Promise { + const line = typeof entry === 'string' ? entry : JSON.stringify(entry); + await fsp.appendFile(join(homeDir, 'session_index.jsonl'), `${line}\n`); + } + it('list returns non-archived sessions by default', async () => { await seedSession('active', { createdAt: 1, updatedAt: 2 }); await seedSession('archived', { archived: true }); @@ -189,6 +196,83 @@ describe('FileSessionIndex (legacy)', () => { expect(await store.countActive(workspaceId)).toBe(2); expect(await store.countActive('wd_unknown')).toBe(0); }); + + it('get locates a session through the legacy index file', async () => { + await seedSession('indexed', { title: 'via index' }); + await seedIndexLine({ + sessionId: 'indexed', + sessionDir: join(sessionsDir, workspaceId, 'indexed'), + workDir: WORK_DIR, + }); + + const store = build(); + const summary = await store.get('indexed'); + expect(summary?.id).toBe('indexed'); + expect(summary?.workspaceId).toBe(workspaceId); + expect(summary?.title).toBe('via index'); + }); + + it('get ignores index lines that fail v1 validation', async () => { + await seedSession('valid', { title: 'ok' }); + // Non-absolute sessionDir. + await seedIndexLine({ sessionId: 'rel', sessionDir: 'relative/path/rel', workDir: WORK_DIR }); + // Outside sessionsDir. + await seedIndexLine({ + sessionId: 'outside', + sessionDir: join(homeDir, 'elsewhere', 'outside'), + workDir: WORK_DIR, + }); + // Basename does not match sessionId. + await seedIndexLine({ + sessionId: 'mismatch', + sessionDir: join(sessionsDir, workspaceId, 'other-name'), + workDir: WORK_DIR, + }); + // Corrupt line. + await seedIndexLine('{ not json'); + + const store = build(); + expect(await store.get('rel')).toBeUndefined(); + expect(await store.get('outside')).toBeUndefined(); + expect(await store.get('mismatch')).toBeUndefined(); + // A corrupt file never breaks unrelated lookups. + expect((await store.get('valid'))?.title).toBe('ok'); + }); + + it('later index lines override earlier ones for the same session', async () => { + const otherWs = 'wd_other'; + await seedSession('moved', { title: 'old location' }); + await seedSession('moved', { title: 'new location' }, otherWs); + await seedIndexLine({ + sessionId: 'moved', + sessionDir: join(sessionsDir, workspaceId, 'moved'), + workDir: WORK_DIR, + }); + await seedIndexLine({ + sessionId: 'moved', + sessionDir: join(sessionsDir, otherWs, 'moved'), + workDir: '/other', + }); + + const store = build(); + const summary = await store.get('moved'); + expect(summary?.workspaceId).toBe(otherWs); + expect(summary?.title).toBe('new location'); + }); + + it('get falls back to the directory scan when the index line is stale', async () => { + await seedSession('real', { title: 'on disk' }); + // Valid-looking line, but the directory was never created. + await seedIndexLine({ + sessionId: 'ghost', + sessionDir: join(sessionsDir, workspaceId, 'ghost'), + workDir: WORK_DIR, + }); + + const store = build(); + expect(await store.get('ghost')).toBeUndefined(); + expect((await store.get('real'))?.title).toBe('on disk'); + }); }); describe('FileSessionIndex (read model)', () => { @@ -363,4 +447,22 @@ describe('FileSessionIndex (read model)', () => { await lockHolder.close(); } }); + + it('cold get backfills through the legacy index locator', async () => { + await seedSession('indexed', { title: 'cold', createdAt: 1, updatedAt: 2 }); + await fsp.appendFile( + join(homeDir, 'session_index.jsonl'), + `${JSON.stringify({ + sessionId: 'indexed', + sessionDir: join(sessionsDir, workspaceId, 'indexed'), + workDir: WORK_DIR, + })}\n`, + ); + + const store = build(); + const got = await store.get('indexed'); + expect(got?.title).toBe('cold'); + // The cold read backfilled the read model. + expect(await queryStore.get(SESSION_COLLECTION, 'indexed')).toMatchObject({ title: 'cold' }); + }); }); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index f0308bfd9..a67f38114 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -34,6 +34,7 @@ import { ISessionActivity } from '#/session/sessionActivity/sessionActivity'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { parseLegacySessionIndexLine } from '#/app/sessionIndex/legacySessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; @@ -219,6 +220,20 @@ function appendLogStoreStub(): IAppendLogStore { }; } +function recordingAppendLogStore(): { + store: IAppendLogStore; + appends: { scope: string; key: string; record: unknown }[]; +} { + const appends: { scope: string; key: string; record: unknown }[] = []; + const store: IAppendLogStore = { + ...appendLogStoreStub(), + append: (scope, key, record) => { + appends.push({ scope, key, record }); + }, + }; + return { store, appends }; +} + function atomicDocumentStoreStub(): IAtomicDocumentStore { return { _serviceBrand: undefined, @@ -974,4 +989,81 @@ describe('SessionLifecycleService', () => { expect(enter).not.toHaveBeenCalled(); }); }); + + describe('legacy session index projection', () => { + it('create appends one v1-compatible line to session_index.jsonl', async () => { + const { store, appends } = recordingAppendLogStore(); + const svc = build([stubPair(IAppendLogStore, store)]); + + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + expect(appends).toEqual([ + { + scope: '', + key: 'session_index.jsonl', + record: { + sessionId: 's1', + sessionDir: '/tmp/sessions/wd_stub/s1', + workDir: '/tmp/proj', + }, + }, + ]); + // The appended record round-trips through v1's line format. + const record = appends[0]!.record; + expect(parseLegacySessionIndexLine(JSON.stringify(record))).toEqual(record); + }); + + it('resume does not append to the legacy index', async () => { + const { store, appends } = recordingAppendLogStore(); + const svc = build([ + stubPair(IAppendLogStore, store), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + + await svc.resume('s1'); + + expect(appends).toEqual([]); + }); + + it('fork appends a line for the target session', async () => { + const { store, appends } = recordingAppendLogStore(); + const svc = build([ + stubPair(IAppendLogStore, store), + stubPair(ISessionActivity, { + _serviceBrand: undefined, + status: () => 'idle' as const, + isIdle: () => true, + }), + stubPair(IWorkspaceRegistry, { + ...workspaceRegistryStub(), + get: () => + Promise.resolve({ + id: 'wd_stub', + root: '/tmp/proj', + name: 'stub', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + ]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + expect(appends.map((a) => (a.record as { sessionId: string }).sessionId)).toEqual([ + 'src', + 'dst', + ]); + expect(appends[1]).toMatchObject({ + scope: '', + key: 'session_index.jsonl', + record: { + sessionId: 'dst', + sessionDir: '/tmp/sessions/wd_stub/dst', + workDir: '/tmp/proj', + }, + }); + }); + }); });