Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/agent/transcript/__tests__/transcript-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

import { createTranscriptMiddleware } from "../transcript-middleware";

describe("createTranscriptMiddleware", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "transcript-mw-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true });
});

it("writes existing messages on beforeAgentRun", async () => {
const mw = createTranscriptMiddleware({ cwd: tmpDir, projectDir: tmpDir });
const agentContext = {
prompt: "test",
messages: [{ role: "user" as const, content: [{ type: "text" as const, text: "hello" }] }],
};

await mw.beforeAgentRun!({ agentContext: agentContext as any });

const files = readdirSync(tmpDir).filter((f: string) => f.endsWith(".jsonl"));
expect(files).toHaveLength(1);

const content = readFileSync(join(tmpDir, files[0]!), "utf-8").trim();
const parsed = JSON.parse(content);
expect(parsed.type).toBe("user");
});

it("incrementally writes new messages on afterAgentStep", async () => {
const mw = createTranscriptMiddleware({ cwd: tmpDir, projectDir: tmpDir });
const agentContext = {
prompt: "test",
messages: [{ role: "user" as const, content: [{ type: "text" as const, text: "hello" }] }],
};

await mw.beforeAgentRun!({ agentContext: agentContext as any });

agentContext.messages.push(
{ role: "assistant" as const, content: [{ type: "text" as const, text: "hi" }] },
);
await mw.afterAgentStep!({ agentContext: agentContext as any, step: 1 });

const files = readdirSync(tmpDir).filter((f: string) => f.endsWith(".jsonl"));
const lines = readFileSync(join(tmpDir, files[0]!), "utf-8").trim().split("\n");
expect(lines).toHaveLength(2);
});
});
134 changes: 134 additions & 0 deletions src/agent/transcript/__tests__/transcript-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, rmSync, readFileSync, utimesSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

import { sanitizeCwd, getProjectDir, appendEntry, loadTranscript, listSessions } from "../transcript-storage";

describe("sanitizeCwd", () => {
it("replaces path separators and colons with dashes", () => {
expect(sanitizeCwd("E:\\Github\\helixent")).toBe("E-Github-helixent");
expect(sanitizeCwd("/home/user/project")).toBe("-home-user-project");
});
});

describe("getProjectDir", () => {
it("returns path under ~/.helixent/projects/ with sanitized cwd", () => {
const dir = getProjectDir("/home/user/myproject");
expect(dir).toContain(".helixent");
expect(dir).toContain("projects");
expect(dir).toContain("-home-user-myproject");
});
});

describe("appendEntry", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "transcript-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true });
});

it("appends a JSONL line to the file", () => {
const filePath = join(tmpDir, "test.jsonl");
const message = { role: "user" as const, content: [{ type: "text" as const, text: "hello" }] };
appendEntry(filePath, message);

const content = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(content.trim());
expect(parsed.type).toBe("user");
expect(parsed.message).toEqual(message);
expect(parsed.timestamp).toBeDefined();
});

it("appends multiple entries on separate lines", () => {
const filePath = join(tmpDir, "test.jsonl");
const msg1 = { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] };
const msg2 = { role: "assistant" as const, content: [{ type: "text" as const, text: "hello" }] };
appendEntry(filePath, msg1);
appendEntry(filePath, msg2);

const lines = readFileSync(filePath, "utf-8").trim().split("\n");
expect(lines).toHaveLength(2);
});
});

describe("loadTranscript", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "transcript-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true });
});

it("reads JSONL and returns messages array", () => {
const filePath = join(tmpDir, "session.jsonl");
const msg = { role: "user" as const, content: [{ type: "text" as const, text: "test" }] };
appendEntry(filePath, msg);

const messages = loadTranscript(filePath);
expect(messages).toHaveLength(1);
expect(messages[0]!.role).toBe("user");
});

it("returns empty array for non-existent file", () => {
const messages = loadTranscript(join(tmpDir, "nope.jsonl"));
expect(messages).toHaveLength(0);
});
});

describe("listSessions", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "transcript-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true });
});

it("returns empty array when no sessions exist", () => {
const sessions = listSessions(tmpDir);
expect(sessions).toHaveLength(0);
});

it("returns sessions sorted by mtime descending", () => {
const msg = { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] };
const path1 = join(tmpDir, "aaa-111.jsonl");
const path2 = join(tmpDir, "bbb-222.jsonl");
appendEntry(path1, msg);
appendEntry(path1, msg);
appendEntry(path2, msg);
// Force different mtimes
const older = new Date(Date.now() - 10000);
const newer = new Date(Date.now());
utimesSync(path1, older, older);
utimesSync(path2, newer, newer);

const sessions = listSessions(tmpDir);
expect(sessions.length).toBeGreaterThanOrEqual(2);
expect(sessions[0]!.id).toBe("bbb-222");
expect(sessions[0]!.messageCount).toBe(1);
expect(sessions[1]!.id).toBe("aaa-111");
expect(sessions[1]!.messageCount).toBe(2);
});

it("respects limit parameter", () => {
const msg = { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] };
appendEntry(join(tmpDir, "a.jsonl"), msg);
appendEntry(join(tmpDir, "b.jsonl"), msg);
appendEntry(join(tmpDir, "c.jsonl"), msg);

const sessions = listSessions(tmpDir, 2);
expect(sessions).toHaveLength(2);
});

it("returns empty array for non-existent directory", () => {
const sessions = listSessions(join(tmpDir, "nonexistent"));
expect(sessions).toHaveLength(0);
});
});
3 changes: 3 additions & 0 deletions src/agent/transcript/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { createTranscriptMiddleware } from "./transcript-middleware";
export { loadTranscript, getLatestSessionPath, getProjectDir, listSessions } from "./transcript-storage";
export type { SessionInfo } from "./transcript-storage";
39 changes: 39 additions & 0 deletions src/agent/transcript/transcript-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { randomUUID } from "crypto";
import { join } from "path";

import type { AgentMiddleware } from "../agent-middleware";
import { appendEntry, getProjectDir } from "./transcript-storage";

/**
* Creates a middleware that persists all messages to a JSONL transcript file.
*/
export function createTranscriptMiddleware(options: {
cwd: string;
projectDir?: string;
}): AgentMiddleware {
let transcriptPath: string;
let lastWrittenIndex = 0;

return {
beforeAgentRun: async ({ agentContext }) => {
const sessionId = randomUUID();
const dir = options.projectDir ?? getProjectDir(options.cwd);
transcriptPath = join(dir, `${sessionId}.jsonl`);

for (const msg of agentContext.messages) {
appendEntry(transcriptPath, msg);
}
lastWrittenIndex = agentContext.messages.length;
return null;
},

afterAgentStep: async ({ agentContext }) => {
const newMessages = agentContext.messages.slice(lastWrittenIndex);
for (const msg of newMessages) {
appendEntry(transcriptPath, msg);
}
lastWrittenIndex = agentContext.messages.length;
return null;
},
};
}
94 changes: 94 additions & 0 deletions src/agent/transcript/transcript-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs";
import { dirname, join } from "path";
import { homedir } from "os";

import type { NonSystemMessage } from "@/foundation";

/**
* Replace path separators and colons with dashes for safe directory names.
*/
export function sanitizeCwd(cwd: string): string {
return cwd.replace(/[/\\:]+/g, "-");
}

/**
* Get the project-specific transcript directory.
*/
export function getProjectDir(cwd: string): string {
return join(homedir(), ".helixent", "projects", sanitizeCwd(cwd));
}

/**
* Get the path to the most recent session file for a project directory.
* Returns null if no sessions exist.
*/
export function getLatestSessionPath(cwd: string): string | null {
const projectDir = getProjectDir(cwd);
if (!existsSync(projectDir)) return null;

const files = readdirSync(projectDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => ({ name: f, mtime: statSync(join(projectDir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);

return files.length > 0 ? join(projectDir, files[0]!.name) : null;
}

/**
* Append a message entry to the transcript file.
*/
export function appendEntry(filePath: string, message: NonSystemMessage): void {
const entry = {
type: message.role,
timestamp: new Date().toISOString(),
message,
};
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
appendFileSync(filePath, JSON.stringify(entry) + "\n", { mode: 0o600 });
}

export type SessionInfo = {
id: string;
path: string;
mtime: Date;
messageCount: number;
};

/**
* List available sessions for a project directory, sorted by most recent first.
*/
export function listSessions(projectDir: string, limit = 5): SessionInfo[] {
if (!existsSync(projectDir)) return [];

return readdirSync(projectDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => {
const filePath = join(projectDir, f);
const stat = statSync(filePath);
const content = readFileSync(filePath, "utf-8");
const lineCount = content.split("\n").filter(Boolean).length;
return {
id: f.replace(".jsonl", ""),
path: filePath,
mtime: stat.mtime,
messageCount: lineCount,
};
})
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
}

/**
* Load all messages from a transcript file.
*/
export function loadTranscript(filePath: string): NonSystemMessage[] {
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, "utf-8");
return content
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line).message as NonSystemMessage);
}
5 changes: 4 additions & 1 deletion src/cli/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Footer } from "./components/footer";
import { Header } from "./components/header";
import { InputBox } from "./components/input-box";
import { MessageHistoryItem } from "./components/message-history";
import { ResumePrompt } from "./components/resume-prompt";
import { StreamingIndicator } from "./components/streaming-indicator";
import { TodoPanel } from "./components/todo-panel";
import { useAgentLoop } from "./hooks/use-agent-loop";
Expand All @@ -29,7 +30,7 @@ export function App({
commands: SlashCommand[];
supportProjectWideAllow?: boolean;
}) {
const { streaming, messages, onSubmit, abort } = useAgentLoop();
const { streaming, messages, onSubmit, abort, resumeRequest, handleResumeSelect } = useAgentLoop();
const { approvalRequest, respondToApproval } = useApprovalManager();
const { askUserQuestionRequest, respondWithAnswers } = useAskUserQuestionManager();
const { latestTodos, todoSnapshots } = useMemo(() => buildTodoViewState(messages), [messages]);
Expand Down Expand Up @@ -72,6 +73,8 @@ export function App({
questions={askUserQuestionRequest.params.questions}
onSubmit={respondWithAnswers}
/>
) : resumeRequest ? (
<ResumePrompt sessions={resumeRequest} onSelect={handleResumeSelect} />
) : (
<InputBox commands={commands} onSubmit={onSubmit} onAbort={abort} />
)}
Expand Down
5 changes: 5 additions & 0 deletions src/cli/tui/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
description: "Exit the TUI session",
type: "builtin",
},
{
name: "resume",
description: "Resume a previous session from this project",
type: "builtin",
},
];

/** Parsed builtin invocation: command name plus any trailing argument string. */
Expand Down
Loading