mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): include context in Radius session shares
This commit is contained in:
parent
a69bef789b
commit
460191cfcf
6 changed files with 389 additions and 213 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
42
packages/coding-agent/src/core/session-export.ts
Normal file
42
packages/coding-agent/src/core/session-export.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
// 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<boolean> {
|
||||
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<void> {
|
||||
// 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<typeof spawn> | 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<void> {
|
||||
|
|
|
|||
210
packages/coding-agent/src/modes/interactive/session-share.ts
Normal file
210
packages/coding-agent/src/modes/interactive/session-share.ts
Normal file
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<typeof spawn> | 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);
|
||||
}
|
||||
122
packages/coding-agent/test/export-jsonl-share.test.ts
Normal file
122
packages/coding-agent/test/export-jsonl-share.test.ts
Normal file
|
|
@ -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<string, unknown>);
|
||||
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<string, unknown>);
|
||||
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<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue