167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
classifyJsonlText,
|
|
classifyRow,
|
|
encodeClaudeCwd,
|
|
jsonlPathFor,
|
|
resolveClaudeConfigDir,
|
|
} from "./clean-poisoned-claude-sessions.js";
|
|
|
|
describe("classifyJsonlText", () => {
|
|
it("returns healthy when the last assistant message.id starts with msg_", () => {
|
|
const text = [
|
|
JSON.stringify({ type: "user" }),
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_first" } }),
|
|
JSON.stringify({ type: "user" }),
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_last" } }),
|
|
].join("\n");
|
|
const result = classifyJsonlText(text);
|
|
expect(result).toEqual({ kind: "healthy", lastAssistantId: "msg_last" });
|
|
});
|
|
|
|
it("returns poisoned when the last assistant message.id is a synthetic UUID", () => {
|
|
const text = [
|
|
JSON.stringify({ type: "user" }),
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_real" } }),
|
|
JSON.stringify({
|
|
type: "assistant",
|
|
message: { id: "f649efd6-1837-4c52-981f-b81fdc0c2ddb" },
|
|
}),
|
|
].join("\n");
|
|
const result = classifyJsonlText(text);
|
|
expect(result).toEqual({
|
|
kind: "poisoned",
|
|
lastAssistantId: "f649efd6-1837-4c52-981f-b81fdc0c2ddb",
|
|
});
|
|
});
|
|
|
|
it("ignores trailing whitespace, blank lines, and malformed JSON", () => {
|
|
const text = [
|
|
"",
|
|
" ",
|
|
"this-is-not-json",
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_only" } }),
|
|
"",
|
|
].join("\n");
|
|
expect(classifyJsonlText(text)).toEqual({
|
|
kind: "healthy",
|
|
lastAssistantId: "msg_only",
|
|
});
|
|
});
|
|
|
|
it("returns no_assistant_entries when the transcript has no assistant rows", () => {
|
|
const text = [
|
|
JSON.stringify({ type: "user" }),
|
|
JSON.stringify({ type: "attachment" }),
|
|
JSON.stringify({ type: "last-prompt", lastPrompt: "x" }),
|
|
].join("\n");
|
|
expect(classifyJsonlText(text)).toEqual({ kind: "no_assistant_entries" });
|
|
});
|
|
|
|
it("treats a CRLF-encoded transcript the same as LF", () => {
|
|
const text = [
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_first" } }),
|
|
JSON.stringify({ type: "assistant", message: { id: "synthetic-uuid" } }),
|
|
].join("\r\n");
|
|
expect(classifyJsonlText(text)).toEqual({
|
|
kind: "poisoned",
|
|
lastAssistantId: "synthetic-uuid",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("encodeClaudeCwd", () => {
|
|
it("mirrors the claude-local adapter encoding rule", () => {
|
|
expect(encodeClaudeCwd("/Users/dj/.paperclip/instances/default/workspaces/abc")).toBe(
|
|
"-Users-dj--paperclip-instances-default-workspaces-abc",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("jsonlPathFor", () => {
|
|
it("returns <root>/projects/<encoded-cwd>/<sessionId>.jsonl", () => {
|
|
const result = jsonlPathFor({
|
|
claudeConfigDir: "/tmp/.claude",
|
|
cwd: "/Users/dj/work",
|
|
sessionId: "abc-123",
|
|
});
|
|
expect(result).toBe(path.join("/tmp/.claude", "projects", "-Users-dj-work", "abc-123.jsonl"));
|
|
});
|
|
});
|
|
|
|
describe("resolveClaudeConfigDir", () => {
|
|
it("prefers the explicit override", () => {
|
|
expect(resolveClaudeConfigDir({}, "/tmp/custom")).toBe(path.resolve("/tmp/custom"));
|
|
});
|
|
it("uses CLAUDE_CONFIG_DIR when no override is given", () => {
|
|
expect(resolveClaudeConfigDir({ CLAUDE_CONFIG_DIR: "/tmp/from-env" } as NodeJS.ProcessEnv)).toBe(
|
|
path.resolve("/tmp/from-env"),
|
|
);
|
|
});
|
|
it("falls back to ~/.claude when no override and no env var", () => {
|
|
const result = resolveClaudeConfigDir({} as NodeJS.ProcessEnv);
|
|
expect(result.endsWith(`${path.sep}.claude`)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("classifyRow", () => {
|
|
const claudeConfigDir = "/tmp/.claude";
|
|
const cwd = "/Users/dj/work";
|
|
const sessionId = "session-1";
|
|
const expectedJsonl = jsonlPathFor({ claudeConfigDir, cwd, sessionId });
|
|
|
|
it("returns skipped_no_session_id when sessionId is null", () => {
|
|
const result = classifyRow(
|
|
{ sessionId: null, cwd, isRemote: false },
|
|
{ claudeConfigDir, readJsonl: () => null },
|
|
);
|
|
expect(result.kind).toBe("skipped_no_session_id");
|
|
});
|
|
|
|
it("returns skipped_remote when the row has remoteExecution params", () => {
|
|
const result = classifyRow(
|
|
{ sessionId, cwd, isRemote: true },
|
|
{ claudeConfigDir, readJsonl: () => null },
|
|
);
|
|
expect(result.kind).toBe("skipped_remote");
|
|
});
|
|
|
|
it("returns missing_jsonl with the expected path when the file does not exist", () => {
|
|
const result = classifyRow(
|
|
{ sessionId, cwd, isRemote: false },
|
|
{ claudeConfigDir, readJsonl: () => null },
|
|
);
|
|
expect(result.kind).toBe("missing_jsonl");
|
|
if (result.kind === "missing_jsonl") {
|
|
expect(result.jsonlPath).toBe(expectedJsonl);
|
|
}
|
|
});
|
|
|
|
it("flags rows whose JSONL last assistant id is non-msg_", () => {
|
|
const jsonl = [
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_first" } }),
|
|
JSON.stringify({ type: "assistant", message: { id: "synthetic-uuid" } }),
|
|
].join("\n");
|
|
const result = classifyRow(
|
|
{ sessionId, cwd, isRemote: false },
|
|
{ claudeConfigDir, readJsonl: () => jsonl },
|
|
);
|
|
expect(result.kind).toBe("poisoned");
|
|
if (result.kind === "poisoned") {
|
|
expect(result.lastAssistantId).toBe("synthetic-uuid");
|
|
}
|
|
});
|
|
|
|
it("leaves valid msg_-prefixed sessions classified as healthy", () => {
|
|
const jsonl = [
|
|
JSON.stringify({ type: "assistant", message: { id: "msg_only" } }),
|
|
].join("\n");
|
|
const result = classifyRow(
|
|
{ sessionId, cwd, isRemote: false },
|
|
{ claudeConfigDir, readJsonl: () => jsonl },
|
|
);
|
|
expect(result.kind).toBe("healthy");
|
|
});
|
|
});
|