From 460191cfcf27d60ff81fc0178812f4ff09e8df06 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 20:45:14 +0200 Subject: [PATCH] feat(coding-agent): include context in Radius session shares --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/core/agent-session.ts | 37 +-- .../coding-agent/src/core/session-export.ts | 42 ++++ .../src/modes/interactive/interactive-mode.ts | 189 +--------------- .../src/modes/interactive/session-share.ts | 210 ++++++++++++++++++ .../test/export-jsonl-share.test.ts | 122 ++++++++++ 6 files changed, 389 insertions(+), 213 deletions(-) create mode 100644 packages/coding-agent/src/core/session-export.ts create mode 100644 packages/coding-agent/src/modes/interactive/session-share.ts create mode 100644 packages/coding-agent/test/export-jsonl-share.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index acb07a6b3..1d87b92d1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,7 +13,7 @@ - Changed the bundled Node.js runtime to load jiti only when importing an extension and Babel only when uncached source needs transformation, reducing CLI startup time and bundle size. - Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. - Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. -- Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. +- Changed session sharing to render clickable terminal links, display only the canonical Radius artifact URL, and include the current system prompt and active tool definitions in Radius session shares. ### Fixed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 17b5b5909..488f5e0ae 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,7 +13,7 @@ * Modes use this class and add their own I/O layer on top. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import type { Agent, @@ -48,7 +48,6 @@ import { } from "@earendil-works/pi-ai/compat"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; -import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; @@ -101,8 +100,9 @@ import { ModelRegistry } from "./model-registry.ts"; import type { ModelRuntime } from "./model-runtime.ts"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; +import { exportSessionToJsonl } from "./session-export.ts"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; -import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts"; +import { getLatestCompactionEntry } from "./session-manager.ts"; import type { SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; @@ -3374,36 +3374,7 @@ export class AgentSession { * @returns The resolved output file path. */ exportToJsonl(outputPath?: string): string { - const filePath = resolvePath( - outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, - process.cwd(), - ); - const dir = dirname(filePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const header: SessionHeader = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: this.sessionManager.getSessionId(), - timestamp: new Date().toISOString(), - cwd: this.sessionManager.getCwd(), - }; - - const branchEntries = this.sessionManager.getBranch(); - const lines = [JSON.stringify(header)]; - - // Re-chain parentIds to form a linear sequence - let prevId: string | null = null; - for (const entry of branchEntries) { - const linear = { ...entry, parentId: prevId }; - lines.push(JSON.stringify(linear)); - prevId = entry.id; - } - - writeFileSync(filePath, `${lines.join("\n")}\n`); - return filePath; + return exportSessionToJsonl(this.sessionManager, outputPath); } // ========================================================================= diff --git a/packages/coding-agent/src/core/session-export.ts b/packages/coding-agent/src/core/session-export.ts new file mode 100644 index 000000000..3607242c3 --- /dev/null +++ b/packages/coding-agent/src/core/session-export.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { resolvePath } from "../utils/paths.ts"; +import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionManager } from "./session-manager.ts"; + +/** Write the current session branch and optional trailing export-only entries as JSONL. */ +export function exportSessionToJsonl( + sessionManager: SessionManager, + outputPath?: string, + createTrailingEntries?: (parentId: string | null, timestamp: string) => readonly object[], +): string { + const filePath = resolvePath( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + process.cwd(), + ); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const timestamp = new Date().toISOString(); + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: sessionManager.getSessionId(), + timestamp, + cwd: sessionManager.getCwd(), + }; + const lines = [JSON.stringify(header)]; + + let parentId: string | null = null; + for (const entry of sessionManager.getBranch()) { + lines.push(JSON.stringify({ ...entry, parentId })); + parentId = entry.id; + } + for (const entry of createTrailingEntries?.(parentId, timestamp) ?? []) { + lines.push(JSON.stringify(entry)); + } + + writeFileSync(filePath, `${lines.join("\n")}\n`); + return filePath; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 10dbb7935..acf96ae2f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -10,7 +10,6 @@ import * as path from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; -import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config"; import type { AutocompleteItem, AutocompleteProvider, @@ -45,8 +44,7 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { spawn, spawnSync } from "child_process"; -import { getAuthCredential } from "../../cli/auth-command.ts"; +import { spawn } from "child_process"; import { APP_NAME, APP_TITLE, @@ -55,7 +53,6 @@ import { getAuthPath, getDebugLogPath, getDocsPath, - getShareViewerUrl, VERSION, } from "../../config.ts"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; @@ -117,7 +114,6 @@ import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version- import { ArminComponent } from "./components/armin.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; import { BashExecutionComponent } from "./components/bash-execution.ts"; -import { BorderedLoader } from "./components/bordered-loader.ts"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; import { CustomEditor } from "./components/custom-editor.ts"; @@ -160,6 +156,7 @@ import { UserMessageSelectorComponent } from "./components/user-message-selector import { editInExternalEditor } from "./external-editor.ts"; import { refreshModelCatalogs } from "./model-catalog-refresh.ts"; import { getModelSearchText } from "./model-search.ts"; +import { shareSession } from "./session-share.ts"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -6092,180 +6089,14 @@ export class InteractiveMode { } private async handleShareCommand(): Promise { - // Radius artifacts natively support JSONL sessions. Gist fallback keeps the legacy HTML upload. - const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); - let htmlFile = null; - - try { - try { - this.session.exportToJsonl(jsonlFile); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - if (await this.tryShareViaRadius(jsonlFile)) return; - - try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); - if (authResult.status !== 0) { - this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); - return; - } - } catch { - this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); - return; - } - - try { - htmlFile = path.join(os.tmpdir(), "session.html"); - await this.session.exportToHtml(htmlFile, { themeName: theme.name }); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - await this.shareViaGist(htmlFile); - } finally { - for (const tmpFile of [jsonlFile, htmlFile]) { - try { - if (tmpFile !== null) { - fs.unlinkSync(tmpFile); - } - } catch { - // Ignore cleanup errors - } - } - } - } - - private async tryShareViaRadius(tmpFile: string): Promise { - const provider = this.session.modelRuntime.getProvider("radius"); - if (!provider) return false; - - const gatewayUrl = DEFAULT_RADIUS_GATEWAY; - - const token = getAuthCredential( - await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), - ); - if (!token) return false; - - const loader = new BorderedLoader(this.ui, theme, "Uploading to Radius..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - loader.onAbort = () => { - this.restoreShareEditor(loader); - this.showStatus("Share cancelled"); - }; - - try { - const body = fs.readFileSync(tmpFile); - const url = new URL("/v1/artifacts", gatewayUrl); - url.searchParams.set("visibility", "organization"); - url.searchParams.set("title", "Pi session"); - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/x-ndjson", - "Content-Length": String(body.byteLength), - }, - body, - signal: loader.signal, - }); - if (loader.signal.aborted) return true; - const json = (await response.json().catch(() => null)) as { - artifact?: { canonical_url: string }; - error?: string; - } | null; - if (loader.signal.aborted) return true; - this.restoreShareEditor(loader); - if (!response.ok || !json?.artifact) { - this.showError( - `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, - ); - return true; - } - const shareUrl = json.artifact.canonical_url; - this.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`); - return true; - } catch (error: unknown) { - if (!loader.signal.aborted) { - this.restoreShareEditor(loader); - this.showError( - `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } - return true; - } - } - - private async shareViaGist(tmpFile: string): Promise { - // Show cancellable loader, replacing the editor - const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - - // Create a secret gist asynchronously - let proc: ReturnType | null = null; - - loader.onAbort = () => { - proc?.kill(); - this.restoreShareEditor(loader); - this.showStatus("Share cancelled"); - }; - - try { - const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { - proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - proc.on("close", (code) => resolve({ stdout, stderr, code })); - }); - - if (loader.signal.aborted) return; - - this.restoreShareEditor(loader); - - if (result.code !== 0) { - const errorMsg = result.stderr?.trim() || "Unknown error"; - this.showError(`Failed to create gist: ${errorMsg}`); - return; - } - - // Extract gist ID from the URL returned by gh - // gh returns something like: https://gist.github.com/username/GIST_ID - const gistUrl = result.stdout?.trim(); - const gistId = gistUrl?.split("/").pop(); - if (!gistId) { - this.showError("Failed to parse gist ID from gh output"); - return; - } - - // Create the preview URL - const previewUrl = getShareViewerUrl(gistId); - this.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`); - } catch (error: unknown) { - if (!loader.signal.aborted) { - this.restoreShareEditor(loader); - this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); - } - } - } - - private restoreShareEditor(loader: BorderedLoader): void { - loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); + await shareSession({ + session: this.session, + ui: this.ui, + editorContainer: this.editorContainer, + editor: this.editor, + showStatus: (message) => this.showStatus(message), + showError: (message) => this.showError(message), + }); } private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { diff --git a/packages/coding-agent/src/modes/interactive/session-share.ts b/packages/coding-agent/src/modes/interactive/session-share.ts new file mode 100644 index 000000000..f9c0000e2 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/session-share.ts @@ -0,0 +1,210 @@ +import { spawn, spawnSync } from "node:child_process"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config"; +import { type Container, type EditorComponent, hyperlink, type TUI } from "@earendil-works/pi-tui"; +import { getAuthCredential } from "../../cli/auth-command.ts"; +import { getShareViewerUrl } from "../../config.ts"; +import type { AgentSession } from "../../core/agent-session.ts"; +import { exportSessionToJsonl } from "../../core/session-export.ts"; +import { BorderedLoader } from "./components/bordered-loader.ts"; +import { theme } from "./theme/theme.ts"; + +interface SessionShareContext { + session: AgentSession; + ui: TUI; + editorContainer: Container; + editor: EditorComponent; + showStatus: (message: string) => void; + showError: (message: string) => void; +} + +/** Export the current branch with presentation metadata for Radius. */ +export function exportSessionForShare(filePath: string, session: AgentSession): void { + exportSessionToJsonl(session.sessionManager, filePath, (parentId, timestamp) => [ + { + type: "custom", + customType: "pi.share", + id: crypto.randomUUID().slice(0, 8), + parentId, + timestamp, + data: { + systemPrompt: session.state.systemPrompt, + tools: session.state.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })), + }, + }, + ]); +} + +/** Share the current session through Radius, falling back to a private gist. */ +export async function shareSession(context: SessionShareContext): Promise { + const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); + let htmlFile: string | null = null; + + try { + try { + exportSessionForShare(jsonlFile, context.session); + } catch (error: unknown) { + context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + if (await tryShareViaRadius(jsonlFile, context)) return; + + try { + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + if (authResult.status !== 0) { + context.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); + return; + } + } catch { + context.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); + return; + } + + try { + htmlFile = path.join(os.tmpdir(), "session.html"); + await context.session.exportToHtml(htmlFile, { themeName: theme.name }); + } catch (error: unknown) { + context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + await shareViaGist(htmlFile, context); + } finally { + for (const tmpFile of [jsonlFile, htmlFile]) { + try { + if (tmpFile !== null) { + fs.unlinkSync(tmpFile); + } + } catch { + // Ignore cleanup errors + } + } + } +} + +async function tryShareViaRadius(tmpFile: string, context: SessionShareContext): Promise { + const provider = context.session.modelRuntime.getProvider("radius"); + if (!provider) return false; + + const token = getAuthCredential( + await context.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), + ); + if (!token) return false; + + const loader = new BorderedLoader(context.ui, theme, "Uploading to Radius..."); + context.editorContainer.clear(); + context.editorContainer.addChild(loader); + context.ui.setFocus(loader); + context.ui.requestRender(); + loader.onAbort = () => { + restoreEditor(loader, context); + context.showStatus("Share cancelled"); + }; + + try { + const body = fs.readFileSync(tmpFile); + const url = new URL("/v1/artifacts", DEFAULT_RADIUS_GATEWAY); + url.searchParams.set("visibility", "organization"); + url.searchParams.set("title", "Pi session"); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-ndjson", + "Content-Length": String(body.byteLength), + }, + body, + signal: loader.signal, + }); + if (loader.signal.aborted) return true; + const json = (await response.json().catch(() => null)) as { + artifact?: { canonical_url: string }; + error?: string; + } | null; + if (loader.signal.aborted) return true; + restoreEditor(loader, context); + if (!response.ok || !json?.artifact) { + context.showError( + `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, + ); + return true; + } + const shareUrl = json.artifact.canonical_url; + context.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`); + return true; + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(loader, context); + context.showError( + `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + return true; + } +} + +async function shareViaGist(tmpFile: string, context: SessionShareContext): Promise { + const loader = new BorderedLoader(context.ui, theme, "Creating gist..."); + context.editorContainer.clear(); + context.editorContainer.addChild(loader); + context.ui.setFocus(loader); + context.ui.requestRender(); + + let proc: ReturnType | null = null; + loader.onAbort = () => { + proc?.kill(); + restoreEditor(loader, context); + context.showStatus("Share cancelled"); + }; + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + }); + + if (loader.signal.aborted) return; + restoreEditor(loader, context); + + if (result.code !== 0) { + context.showError(`Failed to create gist: ${result.stderr?.trim() || "Unknown error"}`); + return; + } + + const gistUrl = result.stdout?.trim(); + const gistId = gistUrl?.split("/").pop(); + if (!gistId) { + context.showError("Failed to parse gist ID from gh output"); + return; + } + + const previewUrl = getShareViewerUrl(gistId); + context.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`); + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(loader, context); + context.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } +} + +function restoreEditor(loader: BorderedLoader, context: SessionShareContext): void { + loader.dispose(); + context.editorContainer.clear(); + context.editorContainer.addChild(context.editor); + context.ui.setFocus(context.editor); +} diff --git a/packages/coding-agent/test/export-jsonl-share.test.ts b/packages/coding-agent/test/export-jsonl-share.test.ts new file mode 100644 index 000000000..5e6685931 --- /dev/null +++ b/packages/coding-agent/test/export-jsonl-share.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { defineTool } from "../src/core/extensions/types.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { exportSessionForShare } from "../src/modes/interactive/session-share.ts"; +import { assistantMsg, userMsg } from "./utilities.ts"; + +describe("JSONL share export", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("adds presentation data without changing conversation IDs or links", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-jsonl-share-")); + tempDirs.push(tempDir); + const sessionManager = SessionManager.inMemory(tempDir); + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager: SettingsManager.inMemory(), + sessionManager, + tools: ["share_tool"], + customTools: [ + defineTool({ + name: "share_tool", + label: "Share Tool", + description: "Render a value for sharing", + parameters: Type.Object({ value: Type.String({ description: "Value to render" }) }), + execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }), + }), + ], + }); + + try { + const userId = sessionManager.appendMessage(userMsg("hello")); + const assistant: AssistantMessage = { + ...assistantMsg(""), + content: [{ type: "toolCall", id: "call-1", name: "share_tool", arguments: { value: "example" } }], + stopReason: "toolUse", + }; + const assistantId = sessionManager.appendMessage(assistant); + const result: ToolResultMessage = { + role: "toolResult", + toolCallId: "call-1", + toolName: "share_tool", + content: [{ type: "text", text: "done" }], + details: {}, + isError: false, + timestamp: Date.now(), + }; + const resultId = sessionManager.appendMessage(result); + const originalEntryIds = sessionManager.getBranch().map((entry) => entry.id); + + const normalPath = join(tempDir, "normal.jsonl"); + session.exportToJsonl(normalPath); + const normalRecords = readFileSync(normalPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(normalRecords.some((record) => record.type === "custom" && record.customType === "pi.share")).toBe( + false, + ); + + const sharePath = join(tempDir, "share.jsonl"); + exportSessionForShare(sharePath, session); + const records = readFileSync(sharePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const conversationRecords = records.slice(1, -1); + expect(conversationRecords.map((record) => record.id)).toEqual(originalEntryIds); + expect(conversationRecords.map((record) => record.parentId)).toEqual([null, ...originalEntryIds.slice(0, -1)]); + expect(conversationRecords.slice(-3).map((record) => record.id)).toEqual([userId, assistantId, resultId]); + + const shareEntry = records.at(-1) as { + id: string; + data?: { + systemPrompt?: string; + tools?: Array>; + }; + }; + expect(shareEntry).toMatchObject({ + type: "custom", + customType: "pi.share", + parentId: resultId, + timestamp: expect.any(String), + }); + expect(shareEntry.data?.systemPrompt).toBe(session.state.systemPrompt); + expect(shareEntry.data?.tools).toEqual([ + expect.objectContaining({ + name: "share_tool", + description: "Render a value for sharing", + }), + ]); + expect(shareEntry.data).not.toHaveProperty("renderedTools"); + expect(shareEntry.data).not.toHaveProperty("theme"); + expect(shareEntry.data).not.toHaveProperty("version"); + + const imported = SessionManager.open(sharePath); + expect(imported.getLeafId()).toBe(shareEntry.id); + expect(imported.buildSessionContext().messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "toolResult", + ]); + } finally { + session.dispose(); + } + }); +});