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
1 change: 1 addition & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,6 @@ export async function printMergedHelp(): Promise<void> {
console.log(` kimchi ${dim("# launch the interactive harness")}`)
console.log(` kimchi -p "explain src/cli.ts" ${dim("# one-shot prompt, no session")}`)
console.log(` kimchi --continue ${dim("# resume the most recent session")}`)
console.log(` kimchi history ${dim("# pick a previous session to resume")}`)
console.log(` kimchi claude -p "review this PR" ${dim("# run Claude Code via Kimchi")}`)
}
291 changes: 291 additions & 0 deletions src/commands/history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const selectMock = vi.fn()
const isCancelMock = vi.fn((value: unknown) => value === Symbol.for("clack:cancel"))

vi.mock("@clack/prompts", () => ({
select: (...args: unknown[]) => selectMock(...args),
isCancel: (value: unknown) => isCancelMock(value),
}))

const spawnMock = vi.fn()

vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
}))

const listMock = vi.fn()

vi.mock("@earendil-works/pi-coding-agent", async () => {
const actual = await vi.importActual("@earendil-works/pi-coding-agent")
return {
...(actual as object),
SessionManager: {
list: (...args: unknown[]) => listMock(...args),
},
}
})

import { EventEmitter } from "node:events"
import { getHistoryHelp, runHistory } from "./history.js"

function makeSession(
overrides: Partial<{
path: string
modified: Date
id: string
cwd: string
created: Date
messageCount: number
firstMessage: string
allMessagesText: string
}> = {},
): {
path: string
modified: Date
id: string
cwd: string
created: Date
messageCount: number
firstMessage: string
allMessagesText: string
} {
return {
path: "/tmp/session.md",
modified: new Date("2025-01-02T00:00:00Z"),
id: "session-1",
cwd: "/tmp",
created: new Date("2025-01-01T00:00:00Z"),
messageCount: 3,
firstMessage: "hello world",
allMessagesText: "hello world",
...overrides,
}
}

function makeFakeChild(): EventEmitter & { kill: ReturnType<typeof vi.fn> } {
const child = new EventEmitter() as EventEmitter & { kill: ReturnType<typeof vi.fn> }
child.kill = vi.fn()
return child
}

describe("runHistory", () => {
let logSpy: ReturnType<typeof vi.spyOn>
let errSpy: ReturnType<typeof vi.spyOn>
let cwdSpy: ReturnType<typeof vi.spyOn>
let stdinIsTTY: boolean | undefined

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
errSpy = vi.spyOn(console, "error").mockImplementation(() => {})
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/tmp")
stdinIsTTY = process.stdin.isTTY
Object.defineProperty(process.stdin, "isTTY", {
value: true,
configurable: true,
})
selectMock.mockReset()
isCancelMock.mockReset()
isCancelMock.mockImplementation((value: unknown) => value === Symbol.for("clack:cancel"))
spawnMock.mockReset()
listMock.mockReset()
})

afterEach(() => {
logSpy.mockRestore()
errSpy.mockRestore()
cwdSpy.mockRestore()
Object.defineProperty(process.stdin, "isTTY", {
value: stdinIsTTY,
configurable: true,
})
})

it("--help prints help and returns 0", async () => {
const code = await runHistory(["--help"])
expect(code).toBe(0)
const out = logSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")
expect(out).toContain("Usage: kimchi history")
expect(out).toContain("--limit")
})

it("-h prints help and returns 0", async () => {
const code = await runHistory(["-h"])
expect(code).toBe(0)
expect(logSpy).toHaveBeenCalled()
})

it("returns 1 with a friendly message when there are no sessions", async () => {
listMock.mockResolvedValue([])
const code = await runHistory([])
expect(code).toBe(1)
expect(listMock).toHaveBeenCalledWith("/tmp")
const out = logSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")
expect(out).toContain("No previous sessions found in /tmp")
})

it("spawns the current executable with --session when a session is selected", async () => {
const session = makeSession({ path: "/tmp/session-a.md" })
listMock.mockResolvedValue([session])
selectMock.mockResolvedValue("/tmp/session-a.md")

const child = makeFakeChild()
spawnMock.mockReturnValue(child)

const promise = runHistory([])
// Give the event listeners a tick to attach.
await new Promise((resolve) => setTimeout(resolve, 10))
child.emit("exit", 42, null)
const code = await promise

expect(code).toBe(42)
expect(spawnMock).toHaveBeenCalledWith(
process.execPath,
[process.argv[1], "--session", "/tmp/session-a.md"],
expect.objectContaining({ stdio: "inherit" }),
)
})

it("returns 130 when the user cancels the prompt", async () => {
listMock.mockResolvedValue([makeSession()])
selectMock.mockResolvedValue(Symbol.for("clack:cancel"))
isCancelMock.mockReturnValue(true)

const code = await runHistory([])

expect(code).toBe(130)
expect(spawnMock).not.toHaveBeenCalled()
})

it("limits the list with --limit", async () => {
const sessions = [
makeSession({ path: "/tmp/session-1.md", modified: new Date("2025-01-03T00:00:00Z"), firstMessage: "one" }),
makeSession({ path: "/tmp/session-2.md", modified: new Date("2025-01-02T00:00:00Z"), firstMessage: "two" }),
]
listMock.mockResolvedValue(sessions)
selectMock.mockResolvedValue("/tmp/session-1.md")

const child = makeFakeChild()
spawnMock.mockReturnValue(child)

const promise = runHistory(["--limit", "1"])
await new Promise((resolve) => setTimeout(resolve, 10))
child.emit("exit", 0, null)
await promise

expect(selectMock).toHaveBeenCalledTimes(1)
const options = selectMock.mock.calls[0]?.[0].options
expect(options).toHaveLength(1)
expect(options[0].value).toBe("/tmp/session-1.md")
})

it("caps --limit at 100", async () => {
const sessions = Array.from({ length: 101 }, (_, i) =>
makeSession({
path: `/tmp/session-${i}.md`,
modified: new Date(2025, 0, i + 1),
firstMessage: `msg-${i}`,
}),
)
listMock.mockResolvedValue(sessions)
selectMock.mockResolvedValue(sessions[0].path)

const child = makeFakeChild()
spawnMock.mockReturnValue(child)

const promise = runHistory(["--limit", "200"])
await new Promise((resolve) => setTimeout(resolve, 10))
child.emit("exit", 0, null)
await promise

const options = selectMock.mock.calls[0]?.[0].options
expect(options).toHaveLength(100)
})

it("returns 2 for an invalid --limit value", async () => {
const code = await runHistory(["--limit", "abc"])
expect(code).toBe(2)
expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("invalid limit")
})

it("returns 2 for a missing --limit value", async () => {
const code = await runHistory(["--limit"])
expect(code).toBe(2)
expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("missing value")
})

it("returns 2 for unknown flags", async () => {
const code = await runHistory(["--bogus"])
expect(code).toBe(2)
expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("unknown flag")
})

it("returns 1 when SessionManager.list throws", async () => {
listMock.mockRejectedValue(new Error("disk unreadable"))
const code = await runHistory([])
expect(code).toBe(1)
expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("failed to list sessions")
})

it("returns 1 when the spawned child fails to start", async () => {
listMock.mockResolvedValue([makeSession()])
selectMock.mockResolvedValue("/tmp/session-a.md")
spawnMock.mockImplementation(() => {
throw new Error("spawn failed")
})

const code = await runHistory([])
expect(code).toBe(1)
expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("failed to resume session")
})

it("prints a numbered list and exits 0 when stdin is not a TTY", async () => {
Object.defineProperty(process.stdin, "isTTY", {
value: false,
configurable: true,
})
listMock.mockResolvedValue([makeSession({ path: "/tmp/session-a.md", firstMessage: "past work" })])

const code = await runHistory([])

expect(code).toBe(0)
expect(selectMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
const out = logSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")
expect(out).toContain("Recent sessions:")
expect(out).toContain("past work")
expect(out).toContain("kimchi --session")
})

it("sorts sessions by modified date descending", async () => {
const sessions = [
makeSession({ path: "/tmp/session-old.md", modified: new Date("2025-01-01T00:00:00Z"), firstMessage: "old" }),
makeSession({ path: "/tmp/session-new.md", modified: new Date("2025-01-03T00:00:00Z"), firstMessage: "new" }),
makeSession({ path: "/tmp/session-mid.md", modified: new Date("2025-01-02T00:00:00Z"), firstMessage: "mid" }),
]
listMock.mockResolvedValue(sessions)
selectMock.mockResolvedValue("/tmp/session-new.md")

const child = makeFakeChild()
spawnMock.mockReturnValue(child)

const promise = runHistory([])
await new Promise((resolve) => setTimeout(resolve, 10))
child.emit("exit", 0, null)
await promise

const options = selectMock.mock.calls[0]?.[0].options
expect(options[0].value).toBe("/tmp/session-new.md")
expect(options[1].value).toBe("/tmp/session-mid.md")
expect(options[2].value).toBe("/tmp/session-old.md")
})
})

describe("getHistoryHelp", () => {
it("returns a help string", () => {
const help = getHistoryHelp()
expect(help).toContain("kimchi history")
expect(help).toContain("--limit")
expect(help).toContain("--help")
})
})
Loading