{"version":3,"file":"session-resume.test.d.ts","sourceRoot":"","sources":["../../src/core/session-resume.test.ts"],"names":[],"mappings":"","sourcesContent":["import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Model } from \"@apholdings/jensen-ai\";\nimport { afterAll, beforeAll, describe, expect, it } from \"vitest\";\nimport { AuthStorage } from \"./auth-storage.js\";\nimport { SESSION_MEMORY_CUSTOM_TYPE } from \"./memory.js\";\nimport { ModelRegistry } from \"./model-registry.js\";\nimport { DefaultResourceLoader } from \"./resource-loader.js\";\nimport { createAgentSession } from \"./sdk.js\";\nimport { SessionManager, validateSessionFile } from \"./session-manager.js\";\nimport { SettingsManager } from \"./settings-manager.js\";\n\nlet root: string;\nlet cwd: string;\nlet agentDir: string;\nlet sessionsDir: string;\nconst originalAgentDir = process.env.JENSEN_CODING_AGENT_DIR;\n\nbeforeAll(() => {\n\troot = mkdtempSync(join(tmpdir(), \"jensen-session-resume-\"));\n\tcwd = join(root, \"repo\");\n\tagentDir = join(root, \"agent\");\n\tsessionsDir = join(agentDir, \"sessions\");\n\tmkdirSync(cwd, { recursive: true });\n\tmkdirSync(sessionsDir, { recursive: true });\n\t// Isolate SessionManager.listAll() from the real user agent directory.\n\tprocess.env.JENSEN_CODING_AGENT_DIR = agentDir;\n});\n\nafterAll(() => {\n\tif (originalAgentDir === undefined) {\n\t\tdelete process.env.JENSEN_CODING_AGENT_DIR;\n\t} else {\n\t\tprocess.env.JENSEN_CODING_AGENT_DIR = originalAgentDir;\n\t}\n\trmSync(root, { recursive: true, force: true });\n});\n\nfunction user(text: string, timestamp = Date.now()) {\n\treturn { role: \"user\" as const, content: text, timestamp };\n}\n\nfunction assistant(text: string, timestamp = Date.now()) {\n\treturn {\n\t\trole: \"assistant\" as const,\n\t\tapi: \"openai-chat\",\n\t\tprovider: \"test-provider\",\n\t\tmodel: \"test-model\",\n\t\tstopReason: \"stop\",\n\t\tcontent: [{ type: \"text\" as const, text }],\n\t\tusage: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\ttotalTokens: 0,\n\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t},\n\t\ttimestamp,\n\t} as unknown as Parameters<SessionManager[\"appendMessage\"]>[0];\n}\n\nfunction textOf(message: unknown): string {\n\tconst content = (message as { content?: unknown }).content;\n\tif (typeof content === \"string\") return content;\n\tif (Array.isArray(content)) {\n\t\treturn content\n\t\t\t.filter((block): block is { type: \"text\"; text: string } => (block as { type?: string }).type === \"text\")\n\t\t\t.map((block) => block.text)\n\t\t\t.join(\" \");\n\t}\n\treturn \"\";\n}\n\nfunction createPersistedSession(id: string, messages: Parameters<SessionManager[\"appendMessage\"]>[0][]): string {\n\tconst session = SessionManager.create(cwd, sessionsDir);\n\tsession.newSession({ id });\n\tfor (const message of messages) {\n\t\tsession.appendMessage(message);\n\t}\n\tconst file = session.getSessionFile();\n\tif (!file) throw new Error(\"Expected persisted session file\");\n\treturn file;\n}\n\ndescribe(\"SessionManager.findById\", () => {\n\tit(\"resolves a session by exact ID\", async () => {\n\t\tconst file = createPersistedSession(\"session-A\", [user(\"hello A\"), assistant(\"reply A\")]);\n\t\tconst found = await SessionManager.findById(\"session-A\", sessionsDir);\n\n\t\texpect(found).not.toBeNull();\n\t\texpect(found?.id).toBe(\"session-A\");\n\t\texpect(found?.path).toBe(file);\n\t});\n\n\tit(\"selects the requested session when multiple sessions exist\", async () => {\n\t\tcreatePersistedSession(\"session-A\", [user(\"a\"), assistant(\"reply a\")]);\n\t\tcreatePersistedSession(\"session-B\", [user(\"b\"), assistant(\"reply b\")]);\n\t\tcreatePersistedSession(\"session-C\", [user(\"c\"), assistant(\"reply c\")]);\n\n\t\tconst found = await SessionManager.findById(\"session-B\", sessionsDir);\n\t\texpect(found?.id).toBe(\"session-B\");\n\t\texpect(textOf(readPersistedMessages(found!.path)[0])).toContain(\"b\");\n\t});\n\n\tit(\"does exact matching, not prefix matching\", async () => {\n\t\tcreatePersistedSession(\"abc123\", [user(\"short\"), assistant(\"reply short\")]);\n\t\tcreatePersistedSession(\"abc1234\", [user(\"long\"), assistant(\"reply long\")]);\n\n\t\texpect((await SessionManager.findById(\"abc123\", sessionsDir))?.id).toBe(\"abc123\");\n\t\texpect(await SessionManager.findById(\"abc\", sessionsDir)).toBeNull();\n\t});\n\n\tit(\"returns null for an unknown ID\", async () => {\n\t\tcreatePersistedSession(\"session-A\", [user(\"hello\"), assistant(\"reply\")]);\n\t\texpect(await SessionManager.findById(\"does-not-exist\", sessionsDir)).toBeNull();\n\t});\n\n\tit(\"rejects path-like IDs without escaping session storage\", async () => {\n\t\tcreatePersistedSession(\"session-A\", [user(\"hello\"), assistant(\"reply\")]);\n\t\texpect(await SessionManager.findById(\"../../etc/passwd\", sessionsDir)).toBeNull();\n\t\texpect(await SessionManager.findById(\"..\\\\..\\\\something\", sessionsDir)).toBeNull();\n\t\texpect(await SessionManager.findById(\"/absolute/path\", sessionsDir)).toBeNull();\n\t\texpect(await SessionManager.findById(\"\", sessionsDir)).toBeNull();\n\t});\n});\n\ndescribe(\"validateSessionFile\", () => {\n\tit(\"accepts a well-formed session file\", () => {\n\t\tconst file = createPersistedSession(\"valid-session\", [user(\"hello\"), assistant(\"reply\")]);\n\t\texpect(validateSessionFile(file)).toEqual({ ok: true });\n\t});\n\n\tit(\"rejects a missing file\", () => {\n\t\texpect(validateSessionFile(join(sessionsDir, \"missing.jsonl\"))).toEqual({\n\t\t\tok: false,\n\t\t\treason: \"session file does not exist\",\n\t\t});\n\t});\n\n\tit(\"rejects an empty file\", () => {\n\t\tconst file = join(sessionsDir, \"empty.jsonl\");\n\t\twriteFileSync(file, \"\");\n\t\texpect(validateSessionFile(file)).toMatchObject({ ok: false, reason: \"session file is empty\" });\n\t});\n\n\tit(\"rejects a malformed JSON line\", () => {\n\t\tconst file = join(sessionsDir, \"malformed.jsonl\");\n\t\twriteFileSync(\n\t\t\tfile,\n\t\t\t[\n\t\t\t\tJSON.stringify({\n\t\t\t\t\ttype: \"session\",\n\t\t\t\t\tversion: 3,\n\t\t\t\t\tid: \"malformed-id\",\n\t\t\t\t\ttimestamp: \"2026-01-01T00:00:00.000Z\",\n\t\t\t\t\tcwd,\n\t\t\t\t}),\n\t\t\t\t'{\"type\":\"message\",\"id\":\"broken\",',\n\t\t\t].join(\"\\n\"),\n\t\t);\n\t\texpect(validateSessionFile(file)).toMatchObject({ ok: false, reason: \"malformed JSON on line 2\" });\n\t});\n\n\tit(\"rejects an invalid session header\", () => {\n\t\tconst file = join(sessionsDir, \"bad-header.jsonl\");\n\t\twriteFileSync(file, JSON.stringify({ type: \"not_session\", id: \"x\", timestamp: \"2026-01-01T00:00:00.000Z\" }));\n\t\texpect(validateSessionFile(file)).toMatchObject({ ok: false, reason: \"invalid session header\" });\n\t});\n\n\tit(\"distinguishes a valid-header session with corrupt later entries without mutating it\", async () => {\n\t\tconst file = join(sessionsDir, \"corrupt-later.jsonl\");\n\t\tconst corruptBytes = [\n\t\t\tJSON.stringify({\n\t\t\t\ttype: \"session\",\n\t\t\t\tversion: 3,\n\t\t\t\tid: \"corrupt-later-id\",\n\t\t\t\ttimestamp: \"2026-01-01T00:00:00.000Z\",\n\t\t\t\tcwd,\n\t\t\t}),\n\t\t\tJSON.stringify({\n\t\t\t\ttype: \"message\",\n\t\t\t\tid: \"entry-1\",\n\t\t\t\tparentId: null,\n\t\t\t\ttimestamp: \"2026-01-01T00:00:01.000Z\",\n\t\t\t\tmessage: user(\"still readable\"),\n\t\t\t}),\n\t\t\t'{\"type\":\"message\",\"id\":\"entry-2\",\"parentId\":\"entry-1\",\"timestamp\":\"2026-01-01T00:00:02.000Z\"',\n\t\t].join(\"\\n\");\n\t\twriteFileSync(file, corruptBytes);\n\n\t\t// findById discovers it via the readable header; validation flags corruption.\n\t\tconst found = await SessionManager.findById(\"corrupt-later-id\", sessionsDir);\n\t\texpect(found?.id).toBe(\"corrupt-later-id\");\n\t\texpect(validateSessionFile(file)).toMatchObject({ ok: false, reason: \"malformed JSON on line 3\" });\n\n\t\t// The original persisted bytes remain intact.\n\t\texpect(readFileSync(file, \"utf8\")).toBe(corruptBytes);\n\t});\n});\n\ndescribe(\"explicit resume session lifecycle\", () => {\n\tit(\"continues the same session across create/resume/continue/resume cycles\", async () => {\n\t\t// Create + persist initial state.\n\t\tconst original = SessionManager.create(cwd, sessionsDir);\n\t\toriginal.newSession({ id: \"resume-e2e\" });\n\t\toriginal.appendMessage(user(\"first turn\"));\n\t\toriginal.appendMessage(assistant(\"first reply\"));\n\t\tconst file = original.getSessionFile();\n\t\texpect(file).toBeTruthy();\n\t\texpect(existsSync(file!)).toBe(true);\n\n\t\t// First resume by ID.\n\t\tconst firstInfo = await SessionManager.findById(\"resume-e2e\", sessionsDir);\n\t\texpect(firstInfo?.id).toBe(\"resume-e2e\");\n\t\texpect(validateSessionFile(firstInfo!.path)).toEqual({ ok: true });\n\n\t\tconst resumed = SessionManager.open(firstInfo!.path, sessionsDir);\n\t\texpect(resumed.getSessionId()).toBe(\"resume-e2e\");\n\t\texpect(resumed.buildSessionContext().messages.map(textOf)).toContain(\"first turn\");\n\n\t\t// Continue the same session.\n\t\tresumed.appendMessage(user(\"second turn\"));\n\t\tresumed.appendMessage(assistant(\"second reply\"));\n\n\t\t// Second resume by ID sees both turns and the same identity.\n\t\tconst secondInfo = await SessionManager.findById(\"resume-e2e\", sessionsDir);\n\t\texpect(secondInfo?.id).toBe(\"resume-e2e\");\n\t\texpect(secondInfo?.path).toBe(file);\n\n\t\tconst resumedAgain = SessionManager.open(secondInfo!.path, sessionsDir);\n\t\texpect(resumedAgain.getSessionId()).toBe(\"resume-e2e\");\n\t\tconst texts = resumedAgain.buildSessionContext().messages.map(textOf);\n\t\texpect(texts).toContain(\"first turn\");\n\t\texpect(texts).toContain(\"second turn\");\n\t});\n\n\tit(\"resumes a compacted session preserving its summary and retained turns\", async () => {\n\t\tconst session = SessionManager.create(cwd, sessionsDir);\n\t\tsession.newSession({ id: \"compacted-e2e\" });\n\t\tsession.appendMessage(user(\"old turn one\"));\n\t\tsession.appendMessage(assistant(\"old reply one\"));\n\t\tconst keptUserId = session.appendMessage(user(\"recent turn\"));\n\t\tsession.appendMessage(assistant(\"recent reply\"));\n\t\tsession.appendCompaction(\"Summary of earlier work\", keptUserId, 120);\n\n\t\tconst info = await SessionManager.findById(\"compacted-e2e\", sessionsDir);\n\t\texpect(info?.id).toBe(\"compacted-e2e\");\n\n\t\tconst resumed = SessionManager.open(info!.path, sessionsDir);\n\t\tconst messages = resumed.buildSessionContext().messages;\n\t\tconst roles = messages.map((m) => (m as { role: string }).role);\n\n\t\texpect(roles).toEqual([\"compactionSummary\", \"user\", \"assistant\"]);\n\t\texpect((messages[0] as { summary: string }).summary).toBe(\"Summary of earlier work\");\n\t\tconst texts = messages.map(textOf);\n\t\texpect(texts).toContain(\"recent turn\");\n\t\texpect(texts).toContain(\"recent reply\");\n\t\texpect(texts).not.toContain(\"old turn one\");\n\t\texpect(texts).not.toContain(\"old reply one\");\n\t});\n\n\tit(\"hydrates a resumed session through createAgentSession with the same ID and history\", async () => {\n\t\tconst restoredModel = createTestModel();\n\t\tconst settingsManager = SettingsManager.inMemory();\n\t\tsettingsManager.setDefaultThinkingLevel(\"high\");\n\t\tconst authStorage = AuthStorage.create(join(agentDir, \"auth.json\"));\n\t\tconst modelRegistry = new ModelRegistry(authStorage);\n\t\tmodelRegistry.registerProvider(restoredModel.provider, {\n\t\t\tapi: restoredModel.api,\n\t\t\tapiKey: \"test-api-key\",\n\t\t\tbaseUrl: restoredModel.baseUrl,\n\t\t\tmodels: [\n\t\t\t\t{\n\t\t\t\t\tid: restoredModel.id,\n\t\t\t\t\tname: restoredModel.name,\n\t\t\t\t\tapi: restoredModel.api,\n\t\t\t\t\treasoning: restoredModel.reasoning,\n\t\t\t\t\tinput: restoredModel.input,\n\t\t\t\t\tcost: restoredModel.cost,\n\t\t\t\t\tcontextWindow: restoredModel.contextWindow,\n\t\t\t\t\tmaxTokens: restoredModel.maxTokens,\n\t\t\t\t},\n\t\t\t],\n\t\t});\n\n\t\tconst persisted = SessionManager.create(cwd, sessionsDir);\n\t\tpersisted.newSession({ id: \"sdk-resume-e2e\" });\n\t\tpersisted.appendModelChange(restoredModel.provider, restoredModel.id);\n\t\tpersisted.appendThinkingLevelChange(\"low\");\n\t\tpersisted.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t{ key: \"validation.token\", value: \"ORION-4821\", timestamp: \"2026-01-01T00:00:00.000Z\" },\n\t\t]);\n\t\tpersisted.appendMessage(user(\"what is the token?\"));\n\t\tpersisted.appendMessage({\n\t\t\trole: \"assistant\",\n\t\t\tapi: restoredModel.api,\n\t\t\tprovider: restoredModel.provider,\n\t\t\tmodel: restoredModel.id,\n\t\t\tstopReason: \"stop\",\n\t\t\tcontent: [{ type: \"text\", text: \"The token is ORION-4821\" }],\n\t\t\tusage: {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t},\n\t\t\ttimestamp: Date.now(),\n\t\t} as unknown as Parameters<SessionManager[\"appendMessage\"]>[0]);\n\n\t\t// Resolve the same way `jensen resume <id>` does, then hydrate.\n\t\tconst info = await SessionManager.findById(\"sdk-resume-e2e\", sessionsDir);\n\t\texpect(info?.id).toBe(\"sdk-resume-e2e\");\n\t\texpect(validateSessionFile(info!.path)).toEqual({ ok: true });\n\n\t\tconst resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });\n\t\tawait resourceLoader.reload();\n\n\t\tconst opened = SessionManager.open(info!.path, sessionsDir);\n\t\tconst { session } = await createAgentSession({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tsettingsManager,\n\t\t\tauthStorage,\n\t\t\tmodelRegistry,\n\t\t\tresourceLoader,\n\t\t\tsessionManager: opened,\n\t\t});\n\n\t\texpect(session.sessionId).toBe(\"sdk-resume-e2e\");\n\t\texpect(session.model?.provider).toBe(restoredModel.provider);\n\t\texpect(session.model?.id).toBe(restoredModel.id);\n\t\texpect(session.thinkingLevel).toBe(\"low\");\n\t\texpect(session.getMemoryItems()).toEqual([\n\t\t\t{ key: \"validation.token\", value: \"ORION-4821\", timestamp: \"2026-01-01T00:00:00.000Z\" },\n\t\t]);\n\t\texpect(session.messages.some((m) => textOf(m).includes(\"what is the token?\"))).toBe(true);\n\t});\n});\n\nfunction readPersistedMessages(path: string): unknown[] {\n\tconst entries = readFileSync(path, \"utf8\")\n\t\t.trim()\n\t\t.split(\"\\n\")\n\t\t.map((line) => JSON.parse(line));\n\treturn entries.filter((e) => e.type === \"message\").map((e) => e.message);\n}\n\nfunction createTestModel(): Model<\"openai-chat\"> {\n\treturn {\n\t\tid: \"sdk-resume-model\",\n\t\tname: \"SDK Resume Model\",\n\t\tprovider: \"sdk-resume-provider\",\n\t\tapi: \"openai-chat\",\n\t\tbaseUrl: \"https://example.test\",\n\t\treasoning: true,\n\t\tinput: [\"text\"],\n\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcontextWindow: 200_000,\n\t\tmaxTokens: 8_000,\n\t};\n}\n"]}