Skip to content
Closed
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
38 changes: 37 additions & 1 deletion hindsight-integrations/coding-agents/src/core/history.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { claudeProjectDir, importLocalHistory } from "./history";

let home: string;
afterEach(() => {
if (home) rmSync(home, { recursive: true, force: true });
vi.unstubAllEnvs();
});

function newHome(): string {
Expand Down Expand Up @@ -127,6 +128,41 @@ describe("local history import", () => {
expect(JSON.stringify(r.sessions)).toContain("found me");
});

// Regression: dshHistory guarded its root with existsSync — true for a regular file — and then
// called readdirSync on it unguarded, so a stray file at ~/.dsh/sessions threw ENOTDIR out of
// importLocalHistory, which documents that it never throws, and whose caller does not catch.
it("treats a regular file at the dsh sessions root as no sessions, not a crash", () => {
const h = newHome();
// The reader prefers $DSH_HOME over the home it is handed; a developer who runs dsh would
// otherwise have this test read their real sessions.
vi.stubEnv("DSH_HOME", join(h, ".dsh"));
mkdirSync(join(h, ".dsh"), { recursive: true });
writeFileSync(join(h, ".dsh", "sessions"), "not a folder");

const r = importLocalHistory("dsh", "/Users/x/dev/myrepo", h);
expect(r.supported).toBe(true);
expect(r.sessions).toEqual([]);
});

it("reads dsh sessions past a stray file where a project directory was expected", () => {
const h = newHome();
const repo = "/Users/x/dev/myrepo";
vi.stubEnv("DSH_HOME", join(h, ".dsh"));
const root = join(h, ".dsh", "sessions");
mkdirSync(join(root, "myrepo", "s1"), { recursive: true });
writeFileSync(
join(root, "myrepo", "s1", "session.jsonl"),
`${JSON.stringify({ id: "s1", cwd: repo })}\n` +
`${JSON.stringify({ type: "user/message", time: Date.parse("2026-08-14T10:00:00Z"), data: { role: "user", content: [{ type: "text", text: "real work" }], source: { kind: "user" } } })}\n`
);
writeFileSync(join(root, "stray"), "not a folder");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This puts the stray file inside root, which is the case dshHistory already handled before this PR: the inner try { readdirSync(dir) } catch { continue } at src/core/history.ts:250-255 swallows it. So the test passes on unmodified main, as the description acknowledges, and it does not exercise the hole the description says was closed "one level up".

That hole is still open. dshHistory guards its root with existsSync(root) (src/core/history.ts:237), which is true for a regular file, and then calls readdirSync(root) unguarded at src/core/history.ts:249. A regular file at ~/.dsh/sessions therefore still throws ENOTDIR out of importLocalHistory, which has no top-level catch (src/core/history.ts:298-320), and out of importConversations, which does not catch either (src/installer.ts:1249) — the exact escape #3771 reports, for dsh instead of Claude.

Suggested change: replace lines 237 and 249 with listDir(root) (dropping the now-redundant existsSync), and move the stray file in this test to root itself so it fails without that change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: dshHistory now lists its root through listDir (the existsSync precheck and the inner try/catch are gone), and this test moved the stray file to the root itself — it fails with ENOTDIR … scandir …/.dsh/sessions against main and passes with the change. Kept the per-project case as a second test since dsh had no importLocalHistory coverage at all.


const r = importLocalHistory("dsh", repo, h);
expect(r.supported).toBe(true);
expect(r.sessions).toHaveLength(1);
expect(JSON.stringify(r.sessions)).toContain("real work");
});

it("reports SQLite-backed harnesses as unsupported with a reason, not an empty success", () => {
const h = newHome();
for (const harness of ["opencode", "kilo", "cursor-cli", "cline-cli"]) {
Expand Down
15 changes: 6 additions & 9 deletions hindsight-integrations/coding-agents/src/core/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,10 @@ function dshHistory(repoDir: string, home: string): HistoryImport {
const root = process.env.DSH_HOME
? join(process.env.DSH_HOME, "sessions")
: join(home, ".dsh", "sessions");
if (!existsSync(root)) return { supported: true, sessions: [] };
// listDir, not existsSync: a regular file at the sessions root passes existsSync and then makes
// readdirSync throw ENOTDIR out of importLocalHistory, which promises never to throw.
const projectDirs = listDir(root).map((project) => join(root, project));
if (projectDirs.length === 0) return { supported: true, sessions: [] };
if (typeof zlib.zstdDecompressSync !== "function") {
return {
supported: false,
Expand All @@ -246,14 +249,8 @@ function dshHistory(repoDir: string, home: string): HistoryImport {
}
const sessions: ChatSession[] = [];
let unattributed = 0;
for (const dir of readdirSync(root).map((project) => join(root, project))) {
let sessionDirs: string[];
try {
sessionDirs = readdirSync(dir).map((id) => join(dir, id));
} catch {
continue; // a stray file where a project directory was expected
}
for (const sessionDir of sessionDirs) {
for (const dir of projectDirs) {
for (const sessionDir of listDir(dir).map((id) => join(dir, id))) {
const file = ["session.jsonl.zstd", "session.jsonl"]
.map((name) => join(sessionDir, name))
.find((candidate) => existsSync(candidate));
Expand Down