From f34a9865f5b9bc502a5285ef81222a53b7282213 Mon Sep 17 00:00:00 2001 From: c001estb0y Date: Sat, 9 May 2026 16:00:28 +0800 Subject: [PATCH] feat: add transcript recording and /resume session restoration - Add transcript middleware that persists messages to JSONL files - Add /resume slash command with interactive session selector - Show summary + last Q&A turn on resume for context continuity --- .../__tests__/transcript-middleware.test.ts | 53 +++++++ .../__tests__/transcript-storage.test.ts | 134 ++++++++++++++++++ src/agent/transcript/index.ts | 3 + src/agent/transcript/transcript-middleware.ts | 39 +++++ src/agent/transcript/transcript-storage.ts | 94 ++++++++++++ src/cli/tui/app.tsx | 5 +- src/cli/tui/command-registry.ts | 5 + src/cli/tui/components/resume-prompt.tsx | 77 ++++++++++ src/cli/tui/hooks/use-agent-loop.ts | 71 +++++++++- src/coding/agents/lead-agent.ts | 3 +- 10 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 src/agent/transcript/__tests__/transcript-middleware.test.ts create mode 100644 src/agent/transcript/__tests__/transcript-storage.test.ts create mode 100644 src/agent/transcript/index.ts create mode 100644 src/agent/transcript/transcript-middleware.ts create mode 100644 src/agent/transcript/transcript-storage.ts create mode 100644 src/cli/tui/components/resume-prompt.tsx diff --git a/src/agent/transcript/__tests__/transcript-middleware.test.ts b/src/agent/transcript/__tests__/transcript-middleware.test.ts new file mode 100644 index 0000000..88ae4bf --- /dev/null +++ b/src/agent/transcript/__tests__/transcript-middleware.test.ts @@ -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); + }); +}); diff --git a/src/agent/transcript/__tests__/transcript-storage.test.ts b/src/agent/transcript/__tests__/transcript-storage.test.ts new file mode 100644 index 0000000..3c116e1 --- /dev/null +++ b/src/agent/transcript/__tests__/transcript-storage.test.ts @@ -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); + }); +}); diff --git a/src/agent/transcript/index.ts b/src/agent/transcript/index.ts new file mode 100644 index 0000000..7e214b3 --- /dev/null +++ b/src/agent/transcript/index.ts @@ -0,0 +1,3 @@ +export { createTranscriptMiddleware } from "./transcript-middleware"; +export { loadTranscript, getLatestSessionPath, getProjectDir, listSessions } from "./transcript-storage"; +export type { SessionInfo } from "./transcript-storage"; diff --git a/src/agent/transcript/transcript-middleware.ts b/src/agent/transcript/transcript-middleware.ts new file mode 100644 index 0000000..4967277 --- /dev/null +++ b/src/agent/transcript/transcript-middleware.ts @@ -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; + }, + }; +} diff --git a/src/agent/transcript/transcript-storage.ts b/src/agent/transcript/transcript-storage.ts new file mode 100644 index 0000000..74e5aae --- /dev/null +++ b/src/agent/transcript/transcript-storage.ts @@ -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); +} diff --git a/src/cli/tui/app.tsx b/src/cli/tui/app.tsx index 4b8ad6c..6400e25 100644 --- a/src/cli/tui/app.tsx +++ b/src/cli/tui/app.tsx @@ -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"; @@ -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]); @@ -72,6 +73,8 @@ export function App({ questions={askUserQuestionRequest.params.questions} onSubmit={respondWithAnswers} /> + ) : resumeRequest ? ( + ) : ( )} diff --git a/src/cli/tui/command-registry.ts b/src/cli/tui/command-registry.ts index 514598a..47f33b1 100644 --- a/src/cli/tui/command-registry.ts +++ b/src/cli/tui/command-registry.ts @@ -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. */ diff --git a/src/cli/tui/components/resume-prompt.tsx b/src/cli/tui/components/resume-prompt.tsx new file mode 100644 index 0000000..bd171f2 --- /dev/null +++ b/src/cli/tui/components/resume-prompt.tsx @@ -0,0 +1,77 @@ +import { Box, Text, useInput } from "ink"; +import React, { useMemo, useState } from "react"; + +import type { SessionInfo } from "@/agent/transcript"; + +function formatDate(date: Date): string { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + return `${y}-${mo}-${d} ${h}:${mi}`; +} + +export function ResumePrompt({ + sessions, + onSelect, +}: { + sessions: SessionInfo[]; + onSelect: (session: SessionInfo | null) => void; +}) { + const options = useMemo( + () => [...sessions.map((s) => ({ type: "session" as const, session: s })), { type: "cancel" as const }], + [sessions], + ); + const [index, setIndex] = useState(0); + + useInput((_input, key) => { + if (key.upArrow) { + setIndex((i) => (i > 0 ? i - 1 : options.length - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => (i < options.length - 1 ? i + 1 : 0)); + return; + } + if (key.return) { + const selected = options[index]!; + if (selected.type === "cancel") { + onSelect(null); + } else { + onSelect(selected.session); + } + return; + } + if (key.escape) { + onSelect(null); + } + }); + + return ( + + Resume a previous session: + + {options.map((opt, i) => { + const marker = i === index ? ">" : " "; + const color = i === index ? "cyan" : undefined; + if (opt.type === "cancel") { + return ( + + {marker} [Cancel] + + ); + } + return ( + + {marker} {formatDate(opt.session.mtime)} ({opt.session.messageCount} messages) + + ); + })} + + + Up/Down to move, Enter to select, Esc to cancel + + + ); +} diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts index 28dcddd..bdd5ed2 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -3,6 +3,8 @@ import type { ReactNode } from "react"; import type { Agent } from "@/agent"; import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation"; +import { listSessions, getProjectDir, loadTranscript } from "@/agent/transcript"; +import type { SessionInfo } from "@/agent/transcript"; import type { PromptSubmission, SlashCommand } from "../command-registry"; import { formatHelp, resolveBuiltinCommand } from "../command-registry"; @@ -15,6 +17,9 @@ type AgentLoopState = { onSubmit: (submission: PromptSubmission) => Promise; abort: () => void; tokenCount: number; + resumeRequest: SessionInfo[] | null; + // eslint-disable-next-line no-unused-vars + handleResumeSelect: (session: SessionInfo | null) => void; }; const AgentLoopContext = createContext(null); @@ -30,6 +35,7 @@ export function AgentLoopProvider({ }) { const [streaming, setStreaming] = useState(false); const [messages, setMessages] = useState([]); + const [resumeRequest, setResumeRequest] = useState(null); const streamingRef = useRef(streaming); const pendingMessagesRef = useRef([]); @@ -79,6 +85,26 @@ export function AgentLoopProvider({ return calculateTotalTokens(messages); }, [messages]); + const handleResumeSelect = useCallback( + (session: SessionInfo | null) => { + setResumeRequest(null); + if (!session) return; + agent.clearMessages(); + const restored = loadTranscript(session.path); + for (const msg of restored) { + agent.messages.push(msg); + } + flushPendingMessages(); + const summaryMsg: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: `Resumed session with ${restored.length} messages.` }], + }; + const recentMessages = getLastCompleteTurn(restored); + setMessages([summaryMsg, ...recentMessages]); + }, + [agent, flushPendingMessages], + ); + const onSubmit = useCallback( async (submission: PromptSubmission) => { const { text, requestedSkillName } = submission; @@ -115,6 +141,20 @@ export function AgentLoopProvider({ return; } + if (invocation?.name === "resume") { + const sessions = listSessions(getProjectDir(process.cwd())); + if (sessions.length === 0) { + const noSessionMsg: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "No previous sessions found." }], + }; + setMessages((prev) => [...prev, noSessionMsg]); + } else { + setResumeRequest(sessions); + } + return; + } + setStreaming(true); try { @@ -133,7 +173,12 @@ export function AgentLoopProvider({ } } catch (error) { if (isAbortError(error)) return; - throw error; + // Display API/model errors as assistant messages instead of crashing + const errorMessage = error instanceof Error ? error.message : String(error); + enqueueMessage({ + role: "assistant", + content: [{ type: "text", text: `Error: ${errorMessage}\n\nYou can try again.` }], + }); } finally { agent.setRequestedSkillName(null); flushPendingMessages(); @@ -151,8 +196,10 @@ export function AgentLoopProvider({ onSubmit, abort, tokenCount, + resumeRequest, + handleResumeSelect, }), - [abort, agent, messages, onSubmit, streaming, tokenCount], + [abort, agent, messages, onSubmit, streaming, tokenCount, resumeRequest, handleResumeSelect], ); return createElement(AgentLoopContext.Provider, { value }, children); @@ -192,3 +239,23 @@ function clearTerminal() { if (!process.stdout.isTTY) return; process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); } + +/** + * Extract the last user question and the last assistant reply from + * the restored messages. Shows where the user left off. + */ +function getLastCompleteTurn(messages: NonSystemMessage[]): NonSystemMessage[] { + // Find the last user message + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]!.role === "user") { + lastUserIdx = i; + break; + } + } + + if (lastUserIdx === -1) return messages.slice(-2); + + // Return the last user message and everything after it (the assistant's response) + return messages.slice(lastUserIdx); +} diff --git a/src/coding/agents/lead-agent.ts b/src/coding/agents/lead-agent.ts index 736b4f5..06397a1 100644 --- a/src/coding/agents/lead-agent.ts +++ b/src/coding/agents/lead-agent.ts @@ -3,6 +3,7 @@ import { join } from "path"; import { Agent } from "@/agent"; import { createSkillsMiddleware } from "@/agent/skills/skills-middleware"; import { createTodoSystem } from "@/agent/todos/todos"; +import { createTranscriptMiddleware } from "@/agent/transcript"; import type { Model, NonSystemMessage, ToolUseContent } from "@/foundation"; import { @@ -63,7 +64,7 @@ export async function createCodingAgent({ const askUserQuestionTool = askUserQuestion ? createAskUserQuestionTool(askUserQuestion) : null; - const middlewares = [createSkillsMiddleware(skillsDirs), todoMiddleware]; + const middlewares = [createSkillsMiddleware(skillsDirs), todoMiddleware, createTranscriptMiddleware({ cwd })]; if (askUser) { middlewares.push( createCodingApprovalMiddleware({