refactor: move directories

This commit is contained in:
d-kimsuon
2025-10-17 20:18:28 +09:00
parent a5d81568bb
commit c745824dbe
78 changed files with 189 additions and 305 deletions

View File

@@ -0,0 +1,47 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("computeClaudeProjectFilePath", () => {
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../../lib/env", () => ({
env: {
get: (key: string) => {
if (key === "GLOBAL_CLAUDE_DIR") {
return TEST_GLOBAL_CLAUDE_DIR;
}
return undefined;
},
},
}));
});
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
);
const projectPath = "/home/me/dev/example";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
expect(result).toBe(expected);
});
it("末尾にスラッシュがある場合も正しく処理される", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
);
const projectPath = "/home/me/dev/example/";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
expect(result).toBe(expected);
});
});

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { claudeProjectsDirPath } from "../../../lib/config/paths";
export function computeClaudeProjectFilePath(projectPath: string): string {
return path.join(
claudeProjectsDirPath,
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
);
}

View File

@@ -0,0 +1,83 @@
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
import { controllablePromise } from "../../../../lib/controllablePromise";
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
export type MessageGenerator = () => AsyncGenerator<
SDKUserMessage,
void,
unknown
>;
export const createMessageGenerator = (): {
generateMessages: MessageGenerator;
setNextMessage: (message: string) => void;
setHooks: (hooks: {
onNextMessageSet?: (message: string) => void | Promise<void>;
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
}) => void;
} => {
let sendMessagePromise = controllablePromise<string>();
let registeredHooks: {
onNextMessageSet: ((message: string) => void | Promise<void>)[];
onNewUserMessageResolved: ((message: string) => void | Promise<void>)[];
} = {
onNextMessageSet: [],
onNewUserMessageResolved: [],
};
const createMessage = (message: string): SDKUserMessage => {
return {
type: "user",
message: {
role: "user",
content: message,
},
} as SDKUserMessage;
};
async function* generateMessages(): ReturnType<MessageGenerator> {
sendMessagePromise = controllablePromise<string>();
while (true) {
const message = await sendMessagePromise.promise;
sendMessagePromise = controllablePromise<string>();
void Promise.allSettled(
registeredHooks.onNewUserMessageResolved.map((hook) => hook(message)),
);
yield createMessage(message);
}
}
const setNextMessage = (message: string) => {
sendMessagePromise.resolve(message);
void Promise.allSettled(
registeredHooks.onNextMessageSet.map((hook) => hook(message)),
);
};
const setHooks = (hooks: {
onNextMessageSet?: (message: string) => void | Promise<void>;
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
}) => {
registeredHooks = {
onNextMessageSet: [
...(hooks?.onNextMessageSet ? [hooks.onNextMessageSet] : []),
...registeredHooks.onNextMessageSet,
],
onNewUserMessageResolved: [
...(hooks?.onNewUserMessageResolved
? [hooks.onNewUserMessageResolved]
: []),
...registeredHooks.onNewUserMessageResolved,
],
};
};
return {
generateMessages,
setNextMessage,
setHooks,
};
};

View File

@@ -0,0 +1,301 @@
import { parseCommandXml } from "./parseCommandXml";
describe("parseCommandXml", () => {
describe("command parsing", () => {
it("parses command-name only", () => {
const input = "<command-name>git status</command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "git status",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("parses command-name with command-args", () => {
const input =
"<command-name>git commit</command-name><command-args>-m 'test'</command-args>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "git commit",
commandArgs: "-m 'test'",
commandMessage: undefined,
});
});
it("parses command-name with command-message", () => {
const input =
"<command-name>ls</command-name><command-message>Listing files</command-message>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "ls",
commandArgs: undefined,
commandMessage: "Listing files",
});
});
it("parses all command tags together", () => {
const input =
"<command-name>npm install</command-name><command-args>--save-dev vitest</command-args><command-message>Installing test dependencies</command-message>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "npm install",
commandArgs: "--save-dev vitest",
commandMessage: "Installing test dependencies",
});
});
it("parses command tags with whitespace in content", () => {
const input =
"<command-name>\n git status \n</command-name><command-args> --short </command-args>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "\n git status \n",
commandArgs: " --short ",
commandMessage: undefined,
});
});
it("parses command tags in different order", () => {
const input =
"<command-message>Test message</command-message><command-args>-v</command-args><command-name>test command</command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "test command",
commandArgs: "-v",
commandMessage: "Test message",
});
});
});
describe("local-command parsing", () => {
it("parses local-command-stdout", () => {
const input = "<local-command-stdout>output text</local-command-stdout>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "local-command",
stdout: "output text",
});
});
it("parses local-command-stdout with multiline content", () => {
const input =
"<local-command-stdout>line1\nline2\nline3</local-command-stdout>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "local-command",
stdout: "line1\nline2\nline3",
});
});
it("parses local-command-stdout with whitespace", () => {
const input =
"<local-command-stdout> \n output with spaces \n </local-command-stdout>";
const result = parseCommandXml(input);
// The regex pattern preserves all whitespace in content
expect(result).toEqual({
kind: "local-command",
stdout: " \n output with spaces \n ",
});
});
});
describe("priority: command over local-command", () => {
it("returns command when both command and local-command tags exist", () => {
const input =
"<command-name>test</command-name><local-command-stdout>output</local-command-stdout>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("test");
}
});
});
describe("fallback to text", () => {
it("returns text when no matching tags found", () => {
const input = "just plain text";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "just plain text",
});
});
it("returns text when tags are not closed properly", () => {
const input = "<command-name>incomplete";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "<command-name>incomplete",
});
});
it("returns text when tags are mismatched", () => {
const input = "<command-name>test</different-tag>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "<command-name>test</different-tag>",
});
});
it("returns text with empty string", () => {
const input = "";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "",
});
});
it("returns text with only unrecognized tags", () => {
const input = "<unknown-tag>content</unknown-tag>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "<unknown-tag>content</unknown-tag>",
});
});
});
describe("edge cases", () => {
it("handles multiple same tags (uses first match)", () => {
const input =
"<command-name>first</command-name><command-name>second</command-name>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("first");
}
});
it("handles empty tag content", () => {
const input = "<command-name></command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("handles tags with special characters in content", () => {
const input =
"<command-name>git commit -m 'test &amp; demo'</command-name>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("git commit -m 'test &amp; demo'");
}
});
it("does not match nested tags (regex limitation)", () => {
const input = "<command-name><nested>inner</nested>outer</command-name>";
const result = parseCommandXml(input);
// The regex won't match properly nested tags due to [^<]* pattern
expect(result.kind).toBe("text");
});
it("handles tags with surrounding text", () => {
const input =
"Some text before <command-name>test</command-name> and after";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "test",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("handles newlines between tags", () => {
const input =
"<command-name>test</command-name>\n\n<command-args>arg</command-args>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "test",
commandArgs: "arg",
commandMessage: undefined,
});
});
it("handles very long content", () => {
const longContent = "x".repeat(10000);
const input = `<command-name>${longContent}</command-name>`;
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe(longContent);
}
});
it("handles tags with attributes (not matched)", () => {
const input = '<command-name attr="value">test</command-name>';
const result = parseCommandXml(input);
// Tags with attributes won't match because regex expects <tag> not <tag attr="...">
expect(result.kind).toBe("text");
});
it("handles self-closing tags (not matched)", () => {
const input = "<command-name />";
const result = parseCommandXml(input);
expect(result.kind).toBe("text");
});
it("handles Unicode content", () => {
const input = "<command-name>テスト コマンド 🚀</command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "テスト コマンド 🚀",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("handles mixed content with multiple tag types", () => {
const input =
"Some text <command-name>cmd</command-name> more text <unknown>tag</unknown>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("cmd");
}
});
});
});

View File

@@ -0,0 +1,74 @@
import { z } from "zod";
const regExp = /<(?<tag>[^>]+)>(?<content>\s*[^<]*?\s*)<\/\k<tag>>/g;
const matchSchema = z.object({
tag: z.string(),
content: z.string(),
});
export const parsedCommandSchema = z.union([
z.object({
kind: z.literal("command"),
commandName: z.string(),
commandArgs: z.string().optional(),
commandMessage: z.string().optional(),
}),
z.object({
kind: z.literal("local-command"),
stdout: z.string(),
}),
z.object({
kind: z.literal("text"),
content: z.string(),
}),
]);
export type ParsedCommand = z.infer<typeof parsedCommandSchema>;
export const parseCommandXml = (content: string): ParsedCommand => {
const matches = Array.from(content.matchAll(regExp))
.map((match) => matchSchema.safeParse(match.groups))
.filter((result) => result.success)
.map((result) => result.data);
if (matches.length === 0) {
return {
kind: "text",
content,
};
}
const commandName = matches.find(
(match) => match.tag === "command-name",
)?.content;
const commandArgs = matches.find(
(match) => match.tag === "command-args",
)?.content;
const commandMessage = matches.find(
(match) => match.tag === "command-message",
)?.content;
const localCommandStdout = matches.find(
(match) => match.tag === "local-command-stdout",
)?.content;
switch (true) {
case commandName !== undefined:
return {
kind: "command",
commandName,
commandArgs,
commandMessage,
};
case localCommandStdout !== undefined:
return {
kind: "local-command",
stdout: localCommandStdout,
};
default:
return {
kind: "text",
content,
};
}
};

View File

@@ -0,0 +1,378 @@
import { describe, expect, it } from "vitest";
import type { ErrorJsonl } from "../../types";
import { parseJsonl } from "./parseJsonl";
describe("parseJsonl", () => {
describe("正常系: 有効なJSONLをパースできる", () => {
it("単一のUserエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("type", "user");
const entry = result[0];
if (entry && entry.type === "user") {
expect(entry.message.content).toBe("Hello");
}
});
it("単一のSummaryエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "summary",
summary: "This is a summary",
leafUuid: "550e8400-e29b-41d4-a716-446655440003",
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("type", "summary");
const entry = result[0];
if (entry && entry.type === "summary") {
expect(entry.summary).toBe("This is a summary");
}
});
it("複数のエントリをパースできる", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({
type: "summary",
summary: "Test summary",
leafUuid: "550e8400-e29b-41d4-a716-446655440002",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "summary");
});
});
describe("エラー系: 不正なJSON行をErrorJsonlとして返す", () => {
it("無効なJSONを渡すとエラーを投げる", () => {
const jsonl = "invalid json";
// parseJsonl の実装は JSON.parse をそのまま呼び出すため、
// 無効な JSON は例外を投げます
expect(() => parseJsonl(jsonl)).toThrow();
});
it("スキーマに合わないオブジェクトをErrorJsonlとして返す", () => {
const jsonl = JSON.stringify({
type: "unknown",
someField: "value",
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const errorEntry = result[0] as ErrorJsonl;
expect(errorEntry.type).toBe("x-error");
expect(errorEntry.lineNumber).toBe(1);
});
it("必須フィールドが欠けているエントリをErrorJsonlとして返す", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
// timestamp, message などの必須フィールドが欠けている
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const errorEntry = result[0] as ErrorJsonl;
expect(errorEntry.type).toBe("x-error");
expect(errorEntry.lineNumber).toBe(1);
});
it("正常なエントリとエラーエントリを混在して返す", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({ type: "invalid-schema" }),
JSON.stringify({
type: "summary",
summary: "Summary text",
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(3);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "x-error");
expect(result[2]).toHaveProperty("type", "summary");
const errorEntry = result[1] as ErrorJsonl;
expect(errorEntry.lineNumber).toBe(2);
});
});
describe("エッジケース: 空行、トリム、複数エントリ", () => {
it("空文字列を渡すと空配列を返す", () => {
const result = parseJsonl("");
expect(result).toEqual([]);
});
it("空行のみを渡すと空配列を返す", () => {
const result = parseJsonl("\n\n\n");
expect(result).toEqual([]);
});
it("前後の空白をトリムする", () => {
const jsonl = `
${JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
})}
`;
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("type", "user");
});
it("行間の空行を除外する", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
"",
"",
JSON.stringify({
type: "summary",
summary: "Summary text",
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "summary");
});
it("空白のみの行を除外する", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
" ",
"\t",
JSON.stringify({
type: "summary",
summary: "Summary text",
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "summary");
});
it("多数のエントリを含むJSONLをパースできる", () => {
const entries = Array.from({ length: 100 }, (_, i) => {
return JSON.stringify({
type: "user",
uuid: `550e8400-e29b-41d4-a716-${String(i).padStart(12, "0")}`,
timestamp: new Date(Date.UTC(2024, 0, 1, 0, 0, i)).toISOString(),
message: {
role: "user",
content: `Message ${i}`,
},
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid:
i > 0
? `550e8400-e29b-41d4-a716-${String(i - 1).padStart(12, "0")}`
: null,
});
});
const jsonl = entries.join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(100);
expect(result.every((entry) => entry.type === "user")).toBe(true);
});
});
describe("行番号の正確性", () => {
it("スキーマ検証エラー時の行番号が正確に記録される", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Line 1" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({ type: "invalid", data: "schema error" }),
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440001",
timestamp: "2024-01-01T00:00:01.000Z",
message: { role: "user", content: "Line 3" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({ type: "another-invalid" }),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(4);
expect((result[1] as ErrorJsonl).lineNumber).toBe(2);
expect((result[1] as ErrorJsonl).type).toBe("x-error");
expect((result[3] as ErrorJsonl).lineNumber).toBe(4);
expect((result[3] as ErrorJsonl).type).toBe("x-error");
});
it("空行フィルタ後の行番号が正確に記録される", () => {
const jsonl = ["", "", JSON.stringify({ type: "invalid-schema" })].join(
"\n",
);
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
// 空行がフィルタされた後のインデックスは0だが、lineNumberは1として記録される
expect((result[0] as ErrorJsonl).lineNumber).toBe(1);
});
});
describe("ConversationSchemaのバリエーション", () => {
it("オプショナルフィールドを含むUserエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: true,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: "550e8400-e29b-41d4-a716-446655440099",
gitBranch: "main",
isMeta: false,
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const entry = result[0];
if (entry && entry.type === "user") {
expect(entry.isSidechain).toBe(true);
expect(entry.parentUuid).toBe("550e8400-e29b-41d4-a716-446655440099");
expect(entry.gitBranch).toBe("main");
}
});
it("nullableフィールドがnullのエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const entry = result[0];
if (entry && entry.type === "user") {
expect(entry.parentUuid).toBeNull();
}
});
});
});

View File

@@ -0,0 +1,23 @@
import { ConversationSchema } from "../../../../lib/conversation-schema";
import type { ErrorJsonl } from "../../types";
export const parseJsonl = (content: string) => {
const lines = content
.trim()
.split("\n")
.filter((line) => line.trim() !== "");
return lines.map((line, index) => {
const parsed = ConversationSchema.safeParse(JSON.parse(line));
if (!parsed.success) {
const errorData: ErrorJsonl = {
type: "x-error",
line,
lineNumber: index + 1,
};
return errorData;
}
return parsed.data;
});
};

View File

@@ -0,0 +1,121 @@
import { Effect } from "effect";
import type { UserEntry } from "../../../../lib/conversation-schema/entry/UserEntrySchema";
import type { InitMessageContext } from "../types";
import * as ClaudeCode from "./ClaudeCode";
import type * as CCTask from "./ClaudeCodeTask";
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
export type CCSessionProcessDef = {
sessionProcessId: string;
projectId: string;
cwd: string;
abortController: AbortController;
setNextMessage: (message: string) => void;
};
type CCSessionProcessStateBase = {
def: CCSessionProcessDef;
tasks: CCTask.ClaudeCodeTaskState[];
};
export type CCSessionProcessPendingState = CCSessionProcessStateBase & {
type: "pending" /* メッセージがまだ解決されていない状態 */;
sessionId?: undefined;
currentTask: CCTask.PendingClaudeCodeTaskState;
};
export type CCSessionProcessNotInitializedState = CCSessionProcessStateBase & {
type: "not_initialized" /* メッセージは解決されているが、init メッセージを未受信 */;
sessionId?: undefined;
currentTask: CCTask.RunningClaudeCodeTaskState;
rawUserMessage: string;
};
export type CCSessionProcessInitializedState = CCSessionProcessStateBase & {
type: "initialized" /* init メッセージを受信した状態 */;
sessionId: string;
currentTask: CCTask.RunningClaudeCodeTaskState;
rawUserMessage: string;
initContext: InitMessageContext;
};
export type CCSessionProcessFileCreatedState = CCSessionProcessStateBase & {
type: "file_created" /* ファイルが作成された状態 */;
sessionId: string;
currentTask: CCTask.RunningClaudeCodeTaskState;
rawUserMessage: string;
initContext: InitMessageContext;
};
export type CCSessionProcessPausedState = CCSessionProcessStateBase & {
type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */;
sessionId: string;
};
export type CCSessionProcessCompletedState = CCSessionProcessStateBase & {
type: "completed" /* paused あるいは起動中のタスクが中断された状態。再開不可 */;
sessionId?: string | undefined;
};
export type CCSessionProcessStatePublic =
| CCSessionProcessInitializedState
| CCSessionProcessFileCreatedState
| CCSessionProcessPausedState;
export type CCSessionProcessState =
| CCSessionProcessPendingState
| CCSessionProcessNotInitializedState
| CCSessionProcessStatePublic
| CCSessionProcessCompletedState;
export const isPublic = (
process: CCSessionProcessState,
): process is CCSessionProcessStatePublic => {
return (
process.type === "initialized" ||
process.type === "file_created" ||
process.type === "paused"
);
};
export const getAliveTasks = (
process: CCSessionProcessState,
): CCTask.AliveClaudeCodeTaskState[] => {
return process.tasks.filter(
(task) => task.status === "pending" || task.status === "running",
);
};
export const createVirtualConversation = (
process: CCSessionProcessState,
ctx: {
sessionId: string;
userMessage: string;
},
) => {
const timestamp = new Date().toISOString();
return Effect.gen(function* () {
const config = yield* ClaudeCode.Config;
const virtualConversation: UserEntry = {
type: "user",
message: {
role: "user",
content: ctx.userMessage,
},
isSidechain: false,
userType: "external",
cwd: process.def.cwd,
sessionId: ctx.sessionId,
version: config.claudeCodeVersion
? ClaudeCodeVersion.versionText(config.claudeCodeVersion)
: "unknown",
uuid: `vc__${ctx.sessionId}__${timestamp}`,
timestamp,
parentUuid: null,
};
return virtualConversation;
});
};

View File

@@ -0,0 +1,94 @@
import { CommandExecutor, Path } from "@effect/platform";
import { NodeContext } from "@effect/platform-node";
import { Effect, Layer } from "effect";
import * as ClaudeCode from "./ClaudeCode";
describe("ClaudeCode.Config", () => {
describe("when environment variable CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH is not set", () => {
it("should correctly parse results of 'which claude' and 'claude --version'", async () => {
const CommandExecutorTest = Layer.effect(
CommandExecutor.CommandExecutor,
Effect.map(CommandExecutor.CommandExecutor, (realExecutor) => ({
...realExecutor,
string: (() => {
const responses = ["/path/to/claude", "1.0.53 (Claude Code)\n"];
return () => Effect.succeed(responses.shift() ?? "");
})(),
})),
).pipe(Layer.provide(NodeContext.layer));
const config = await Effect.runPromise(
ClaudeCode.Config.pipe(
Effect.provide(Path.layer),
Effect.provide(CommandExecutorTest),
),
);
expect(config.claudeCodeExecutablePath).toBe("/path/to/claude");
expect(config.claudeCodeVersion).toStrictEqual({
major: 1,
minor: 0,
patch: 53,
});
});
});
});
describe("ClaudeCode.AvailableFeatures", () => {
describe("when claudeCodeVersion is null", () => {
it("canUseTool and uuidOnSDKMessage should be false", () => {
const features = ClaudeCode.getAvailableFeatures(null);
expect(features.canUseTool).toBe(false);
expect(features.uuidOnSDKMessage).toBe(false);
});
});
describe("when claudeCodeVersion is v1.0.81", () => {
it("canUseTool should be false, uuidOnSDKMessage should be false", () => {
const features = ClaudeCode.getAvailableFeatures({
major: 1,
minor: 0,
patch: 81,
});
expect(features.canUseTool).toBe(false);
expect(features.uuidOnSDKMessage).toBe(false);
});
});
describe("when claudeCodeVersion is v1.0.82", () => {
it("canUseTool should be true, uuidOnSDKMessage should be false", () => {
const features = ClaudeCode.getAvailableFeatures({
major: 1,
minor: 0,
patch: 82,
});
expect(features.canUseTool).toBe(true);
expect(features.uuidOnSDKMessage).toBe(false);
});
});
describe("when claudeCodeVersion is v1.0.85", () => {
it("canUseTool should be true, uuidOnSDKMessage should be false", () => {
const features = ClaudeCode.getAvailableFeatures({
major: 1,
minor: 0,
patch: 85,
});
expect(features.canUseTool).toBe(true);
expect(features.uuidOnSDKMessage).toBe(false);
});
});
describe("when claudeCodeVersion is v1.0.86", () => {
it("canUseTool should be true, uuidOnSDKMessage should be true", () => {
const features = ClaudeCode.getAvailableFeatures({
major: 1,
minor: 0,
patch: 86,
});
expect(features.canUseTool).toBe(true);
expect(features.uuidOnSDKMessage).toBe(true);
});
});
});

View File

@@ -0,0 +1,81 @@
import { query as originalQuery } from "@anthropic-ai/claude-code";
import { Command, Path } from "@effect/platform";
import { Effect } from "effect";
import { env } from "../../../lib/env";
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
type CCQuery = typeof originalQuery;
type CCQueryPrompt = Parameters<CCQuery>[0]["prompt"];
type CCQueryOptions = NonNullable<Parameters<CCQuery>[0]["options"]>;
export const Config = Effect.gen(function* () {
const path = yield* Path.Path;
const specifiedExecutablePath = env.get(
"CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH",
);
const claudeCodeExecutablePath =
specifiedExecutablePath !== undefined
? path.resolve(specifiedExecutablePath)
: (yield* Command.string(
Command.make("which", "claude").pipe(
Command.env({
PATH: env.get("PATH"),
}),
Command.runInShell(true),
),
)).trim();
const claudeCodeVersion = ClaudeCodeVersion.fromCLIString(
yield* Command.string(Command.make(claudeCodeExecutablePath, "--version")),
);
return {
claudeCodeExecutablePath,
claudeCodeVersion,
};
});
export const getAvailableFeatures = (
claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null,
) => ({
canUseTool:
claudeCodeVersion !== null
? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, {
major: 1,
minor: 0,
patch: 82,
})
: false,
uuidOnSDKMessage:
claudeCodeVersion !== null
? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, {
major: 1,
minor: 0,
patch: 86,
})
: false,
});
export const query = (prompt: CCQueryPrompt, options: CCQueryOptions) => {
const { canUseTool, permissionMode, ...baseOptions } = options;
return Effect.gen(function* () {
const { claudeCodeExecutablePath, claudeCodeVersion } = yield* Config;
const availableFeatures = getAvailableFeatures(claudeCodeVersion);
return originalQuery({
prompt,
options: {
pathToClaudeCodeExecutable: claudeCodeExecutablePath,
...baseOptions,
...(availableFeatures.canUseTool
? { canUseTool, permissionMode }
: {
permissionMode: "bypassPermissions",
}),
},
});
});
};

View File

@@ -0,0 +1,59 @@
type BaseClaudeCodeTaskDef = {
taskId: string;
};
export type NewClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
type: "new";
sessionId?: undefined;
baseSessionId?: undefined;
};
export type ContinueClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
type: "continue";
sessionId: string;
baseSessionId: string;
};
export type ResumeClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
type: "resume";
sessionId?: undefined;
baseSessionId: string;
};
export type ClaudeCodeTaskDef =
| NewClaudeCodeTaskDef
| ContinueClaudeCodeTaskDef
| ResumeClaudeCodeTaskDef;
type ClaudeCodeTaskStateBase = {
def: ClaudeCodeTaskDef;
};
export type PendingClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
status: "pending";
sessionId?: undefined;
};
export type RunningClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
status: "running";
sessionId?: undefined;
};
export type CompletedClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
status: "completed";
sessionId?: string | undefined;
};
export type FailedClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
status: "failed";
error: unknown;
};
export type AliveClaudeCodeTaskState =
| PendingClaudeCodeTaskState
| RunningClaudeCodeTaskState;
export type ClaudeCodeTaskState =
| AliveClaudeCodeTaskState
| CompletedClaudeCodeTaskState
| FailedClaudeCodeTaskState;

View File

@@ -0,0 +1,84 @@
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
describe("ClaudeCodeVersion.fromCLIString", () => {
describe("with valid version string", () => {
it("should correctly parse CLI output format: 'x.x.x (Claude Code)'", () => {
const version = ClaudeCodeVersion.fromCLIString("1.0.53 (Claude Code)\n");
expect(version).toStrictEqual({
major: 1,
minor: 0,
patch: 53,
});
});
});
describe("with invalid version string", () => {
it("should return null for non-version format strings", () => {
const version = ClaudeCodeVersion.fromCLIString("invalid version");
expect(version).toBeNull();
});
});
});
describe("ClaudeCodeVersion.versionText", () => {
it("should convert version object to 'major.minor.patch' format string", () => {
const text = ClaudeCodeVersion.versionText({
major: 1,
minor: 0,
patch: 53,
});
expect(text).toBe("1.0.53");
});
});
describe("ClaudeCodeVersion.equals", () => {
describe("with same version", () => {
it("should return true", () => {
const a = { major: 1, minor: 0, patch: 53 };
const b = { major: 1, minor: 0, patch: 53 };
expect(ClaudeCodeVersion.equals(a, b)).toBe(true);
});
});
});
describe("ClaudeCodeVersion.greaterThan", () => {
describe("when a is greater than b", () => {
it("should return true when major is greater", () => {
const a = { major: 2, minor: 0, patch: 0 };
const b = { major: 1, minor: 9, patch: 99 };
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
});
it("should return true when major is same and minor is greater", () => {
const a = { major: 1, minor: 1, patch: 0 };
const b = { major: 1, minor: 0, patch: 99 };
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
});
it("should return true when major and minor are same and patch is greater", () => {
const a = { major: 1, minor: 0, patch: 86 };
const b = { major: 1, minor: 0, patch: 85 };
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
});
});
describe("when a is less than or equal to b", () => {
it("should return false for same version", () => {
const a = { major: 1, minor: 0, patch: 53 };
const b = { major: 1, minor: 0, patch: 53 };
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
});
it("should return false when a is less than b", () => {
const a = { major: 1, minor: 0, patch: 81 };
const b = { major: 1, minor: 0, patch: 82 };
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
});
it("should return false when major is less", () => {
const a = { major: 1, minor: 9, patch: 99 };
const b = { major: 2, minor: 0, patch: 0 };
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
});
});
});

View File

@@ -0,0 +1,47 @@
import { z } from "zod";
const versionRegex = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/;
const versionSchema = z
.object({
major: z.string().transform((value) => Number.parseInt(value, 10)),
minor: z.string().transform((value) => Number.parseInt(value, 10)),
patch: z.string().transform((value) => Number.parseInt(value, 10)),
})
.refine((data) =>
[data.major, data.minor, data.patch].every((value) => !Number.isNaN(value)),
);
export type ClaudeCodeVersion = z.infer<typeof versionSchema>;
export const fromCLIString = (
versionOutput: string,
): ClaudeCodeVersion | null => {
const groups = versionOutput.trim().match(versionRegex)?.groups;
if (groups === undefined) {
return null;
}
const parsed = versionSchema.safeParse(groups);
if (!parsed.success) {
return null;
}
return parsed.data;
};
export const versionText = (version: ClaudeCodeVersion) =>
`${version.major}.${version.minor}.${version.patch}`;
export const equals = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) =>
a.major === b.major && a.minor === b.minor && a.patch === b.patch;
export const greaterThan = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) =>
a.major > b.major ||
(a.major === b.major &&
(a.minor > b.minor || (a.minor === b.minor && a.patch > b.patch)));
export const greaterThanOrEqual = (
a: ClaudeCodeVersion,
b: ClaudeCodeVersion,
) => equals(a, b) || greaterThan(a, b);

View File

@@ -0,0 +1,393 @@
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
import type { FileSystem, Path } from "@effect/platform";
import type { CommandExecutor } from "@effect/platform/CommandExecutor";
import { Context, Effect, Layer, Runtime } from "effect";
import { ulid } from "ulid";
import { controllablePromise } from "../../../../lib/controllablePromise";
import type { Config } from "../../../lib/config/config";
import type { InferEffect } from "../../../lib/effect/types";
import { EventBus } from "../../events/services/EventBus";
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
import { VirtualConversationDatabase } from "../../session/infrastructure/VirtualConversationDatabase";
import type { SessionMetaService } from "../../session/services/SessionMetaService";
import { createMessageGenerator } from "../functions/createMessageGenerator";
import * as CCSessionProcess from "../models/CCSessionProcess";
import * as ClaudeCode from "../models/ClaudeCode";
import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService";
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
export type MessageGenerator = () => AsyncGenerator<
SDKUserMessage,
void,
unknown
>;
const LayerImpl = Effect.gen(function* () {
const eventBusService = yield* EventBus;
const sessionRepository = yield* SessionRepository;
const sessionProcessService = yield* ClaudeCodeSessionProcessService;
const virtualConversationDatabase = yield* VirtualConversationDatabase;
const permissionService = yield* ClaudeCodePermissionService;
const runtime = yield* Effect.runtime<
| FileSystem.FileSystem
| Path.Path
| CommandExecutor
| VirtualConversationDatabase
| SessionMetaService
| ClaudeCodePermissionService
>();
const continueTask = (options: {
sessionProcessId: string;
baseSessionId: string;
message: string;
}) => {
const { sessionProcessId, baseSessionId, message } = options;
return Effect.gen(function* () {
const { sessionProcess, task } =
yield* sessionProcessService.continueSessionProcess({
sessionProcessId,
taskDef: {
type: "continue",
sessionId: baseSessionId,
baseSessionId: baseSessionId,
taskId: ulid(),
},
});
const virtualConversation =
yield* CCSessionProcess.createVirtualConversation(sessionProcess, {
sessionId: baseSessionId,
userMessage: message,
});
yield* virtualConversationDatabase.createVirtualConversation(
sessionProcess.def.projectId,
baseSessionId,
[virtualConversation],
);
sessionProcess.def.setNextMessage(message);
return {
sessionProcess,
task,
};
});
};
const startTask = (options: {
config: Config;
baseSession: {
cwd: string;
projectId: string;
sessionId?: string;
};
message: string;
}) => {
const { baseSession, message, config } = options;
return Effect.gen(function* () {
const {
generateMessages,
setNextMessage,
setHooks: setMessageGeneratorHooks,
} = createMessageGenerator();
const { sessionProcess, task } =
yield* sessionProcessService.startSessionProcess({
sessionDef: {
projectId: baseSession.projectId,
cwd: baseSession.cwd,
abortController: new AbortController(),
setNextMessage,
sessionProcessId: ulid(),
},
taskDef:
baseSession.sessionId === undefined
? {
type: "new",
taskId: ulid(),
}
: {
type: "resume",
taskId: ulid(),
sessionId: undefined,
baseSessionId: baseSession.sessionId,
},
});
const sessionInitializedPromise = controllablePromise<{
sessionId: string;
}>();
const sessionFileCreatedPromise = controllablePromise<{
sessionId: string;
}>();
setMessageGeneratorHooks({
onNewUserMessageResolved: async (message) => {
Effect.runFork(
sessionProcessService.toNotInitializedState({
sessionProcessId: sessionProcess.def.sessionProcessId,
rawUserMessage: message,
}),
);
},
});
const handleMessage = (message: SDKMessage) =>
Effect.gen(function* () {
const processState = yield* sessionProcessService.getSessionProcess(
sessionProcess.def.sessionProcessId,
);
if (processState.type === "completed") {
return "break" as const;
}
if (processState.type === "paused") {
// rule: paused は not_initialized に更新されてからくる想定
yield* Effect.die(
new Error("Illegal state: paused is not expected"),
);
}
if (
message.type === "system" &&
message.subtype === "init" &&
processState.type === "not_initialized"
) {
yield* sessionProcessService.toInitializedState({
sessionProcessId: processState.def.sessionProcessId,
initContext: {
initMessage: message,
},
});
// Virtual Conversation Creation
const virtualConversation =
yield* CCSessionProcess.createVirtualConversation(processState, {
sessionId: message.session_id,
userMessage: processState.rawUserMessage,
});
if (processState.currentTask.def.type === "new") {
// 末尾に追加するだけで OK
yield* virtualConversationDatabase.createVirtualConversation(
baseSession.projectId,
message.session_id,
[virtualConversation],
);
} else if (processState.currentTask.def.type === "resume") {
const existingSession = yield* sessionRepository.getSession(
processState.def.projectId,
processState.currentTask.def.baseSessionId,
);
const copiedConversations =
existingSession.session === null
? []
: existingSession.session.conversations;
yield* virtualConversationDatabase.createVirtualConversation(
processState.def.projectId,
message.session_id,
[...copiedConversations, virtualConversation],
);
} else {
// do nothing
}
sessionInitializedPromise.resolve({
sessionId: message.session_id,
});
yield* eventBusService.emit("sessionListChanged", {
projectId: processState.def.projectId,
});
yield* eventBusService.emit("sessionChanged", {
projectId: processState.def.projectId,
sessionId: message.session_id,
});
return "continue" as const;
}
if (
message.type === "assistant" &&
processState.type === "initialized"
) {
yield* sessionProcessService.toFileCreatedState({
sessionProcessId: processState.def.sessionProcessId,
});
sessionFileCreatedPromise.resolve({
sessionId: message.session_id,
});
yield* virtualConversationDatabase.deleteVirtualConversations(
message.session_id,
);
}
if (
message.type === "result" &&
processState.type === "file_created"
) {
yield* sessionProcessService.toPausedState({
sessionProcessId: processState.def.sessionProcessId,
resultMessage: message,
});
yield* eventBusService.emit("sessionChanged", {
projectId: processState.def.projectId,
sessionId: message.session_id,
});
return "continue" as const;
}
return "continue" as const;
});
const handleSessionProcessDaemon = async () => {
const messageIter = await Runtime.runPromise(runtime)(
Effect.gen(function* () {
const permissionOptions =
yield* permissionService.createCanUseToolRelatedOptions({
taskId: task.def.taskId,
config,
sessionId: task.def.baseSessionId,
});
return yield* ClaudeCode.query(generateMessages(), {
resume: task.def.baseSessionId,
cwd: sessionProcess.def.cwd,
abortController: sessionProcess.def.abortController,
...permissionOptions,
});
}),
);
setNextMessage(message);
try {
for await (const message of messageIter) {
const result = await Runtime.runPromise(runtime)(
handleMessage(message),
).catch((error) => {
// iter 自体が落ちてなければ継続したいので握りつぶす
Effect.runFork(
sessionProcessService.changeTaskState({
sessionProcessId: sessionProcess.def.sessionProcessId,
taskId: task.def.taskId,
nextTask: {
status: "failed",
def: task.def,
error: error,
},
}),
);
return "continue" as const;
});
if (result === "break") {
break;
} else {
}
}
} catch (error) {
await Effect.runPromise(
sessionProcessService.changeTaskState({
sessionProcessId: sessionProcess.def.sessionProcessId,
taskId: task.def.taskId,
nextTask: {
status: "failed",
def: task.def,
error: error,
},
}),
);
}
};
const daemonPromise = handleSessionProcessDaemon()
.catch((error) => {
console.error("Error occur in task daemon process", error);
throw error;
})
.finally(() => {
Effect.runFork(
Effect.gen(function* () {
const currentProcess =
yield* sessionProcessService.getSessionProcess(
sessionProcess.def.sessionProcessId,
);
yield* sessionProcessService.toCompletedState({
sessionProcessId: currentProcess.def.sessionProcessId,
});
}),
);
});
return {
sessionProcess,
task,
daemonPromise,
awaitSessionInitialized: async () =>
await sessionInitializedPromise.promise,
awaitSessionFileCreated: async () =>
await sessionFileCreatedPromise.promise,
};
});
};
const getPublicSessionProcesses = () =>
Effect.gen(function* () {
const processes = yield* sessionProcessService.getSessionProcesses();
return processes.filter((process) => CCSessionProcess.isPublic(process));
});
const abortTask = (sessionProcessId: string): Effect.Effect<void, Error> =>
Effect.gen(function* () {
const currentProcess =
yield* sessionProcessService.getSessionProcess(sessionProcessId);
yield* sessionProcessService.toCompletedState({
sessionProcessId: currentProcess.def.sessionProcessId,
error: new Error("Task aborted"),
});
});
const abortAllTasks = () =>
Effect.gen(function* () {
const processes = yield* sessionProcessService.getSessionProcesses();
for (const process of processes) {
yield* sessionProcessService.toCompletedState({
sessionProcessId: process.def.sessionProcessId,
error: new Error("Task aborted"),
});
}
});
return {
continueTask,
startTask,
abortTask,
abortAllTasks,
getPublicSessionProcesses,
};
});
export type IClaudeCodeLifeCycleService = InferEffect<typeof LayerImpl>;
export class ClaudeCodeLifeCycleService extends Context.Tag(
"ClaudeCodeLifeCycleService",
)<ClaudeCodeLifeCycleService, IClaudeCodeLifeCycleService>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,158 @@
import type { CanUseTool } from "@anthropic-ai/claude-code";
import { Context, Effect, Layer, Ref } from "effect";
import { ulid } from "ulid";
import type {
PermissionRequest,
PermissionResponse,
} from "../../../../types/permissions";
import type { Config } from "../../../lib/config/config";
import type { InferEffect } from "../../../lib/effect/types";
import { EventBus } from "../../events/services/EventBus";
import * as ClaudeCode from "../models/ClaudeCode";
const LayerImpl = Effect.gen(function* () {
const pendingPermissionRequestsRef = yield* Ref.make<
Map<string, PermissionRequest>
>(new Map());
const permissionResponsesRef = yield* Ref.make<
Map<string, PermissionResponse>
>(new Map());
const eventBus = yield* EventBus;
const waitPermissionResponse = (
request: PermissionRequest,
options: { timeoutMs: number },
) =>
Effect.gen(function* () {
yield* Ref.update(pendingPermissionRequestsRef, (requests) => {
requests.set(request.id, request);
return requests;
});
yield* eventBus.emit("permissionRequested", {
permissionRequest: request,
});
let passedMs = 0;
let response: PermissionResponse | null = null;
while (passedMs < options.timeoutMs) {
const responses = yield* Ref.get(permissionResponsesRef);
response = responses.get(request.id) ?? null;
if (response !== null) {
break;
}
yield* Effect.sleep(1000);
passedMs += 1000;
}
return response;
});
const createCanUseToolRelatedOptions = (options: {
taskId: string;
config: Config;
sessionId?: string;
}) => {
const { taskId, config, sessionId } = options;
return Effect.gen(function* () {
const claudeCodeConfig = yield* ClaudeCode.Config;
if (
!ClaudeCode.getAvailableFeatures(claudeCodeConfig.claudeCodeVersion)
.canUseTool
) {
return {
permissionMode: "bypassPermissions",
} as const;
}
const canUseTool: CanUseTool = async (toolName, toolInput, _options) => {
if (config.permissionMode !== "default") {
// Convert Claude Code permission modes to canUseTool behaviors
if (
config.permissionMode === "bypassPermissions" ||
config.permissionMode === "acceptEdits"
) {
return {
behavior: "allow" as const,
updatedInput: toolInput,
};
} else {
// plan mode should deny actual tool execution
return {
behavior: "deny" as const,
message: "Tool execution is disabled in plan mode",
};
}
}
const permissionRequest: PermissionRequest = {
id: ulid(),
taskId,
sessionId,
toolName,
toolInput,
timestamp: Date.now(),
};
const response = await Effect.runPromise(
waitPermissionResponse(permissionRequest, { timeoutMs: 60000 }),
);
if (response === null) {
return {
behavior: "deny" as const,
message: "Permission request timed out",
};
}
if (response.decision === "allow") {
return {
behavior: "allow" as const,
updatedInput: toolInput,
};
} else {
return {
behavior: "deny" as const,
message: "Permission denied by user",
};
}
};
return {
canUseTool,
permissionMode: config.permissionMode,
} as const;
});
};
const respondToPermissionRequest = (
response: PermissionResponse,
): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Ref.update(permissionResponsesRef, (responses) => {
responses.set(response.permissionRequestId, response);
return responses;
});
yield* Ref.update(pendingPermissionRequestsRef, (requests) => {
requests.delete(response.permissionRequestId);
return requests;
});
});
return {
createCanUseToolRelatedOptions,
respondToPermissionRequest,
};
});
export type IClaudeCodePermissionService = InferEffect<typeof LayerImpl>;
export class ClaudeCodePermissionService extends Context.Tag(
"ClaudeCodePermissionService",
)<ClaudeCodePermissionService, IClaudeCodePermissionService>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,908 @@
import type {
SDKResultMessage,
SDKSystemMessage,
} from "@anthropic-ai/claude-code";
import { Effect, Layer } from "effect";
import { describe, expect, it } from "vitest";
import { EventBus } from "../../events/services/EventBus";
import type { InternalEventDeclaration } from "../../events/types/InternalEventDeclaration";
import type * as CCSessionProcess from "../models/CCSessionProcess";
import type * as CCTask from "../models/ClaudeCodeTask";
import type { InitMessageContext } from "../types";
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
// Helper function to create mock session process definition
const createMockSessionProcessDef = (
sessionProcessId: string,
projectId = "test-project",
): CCSessionProcess.CCSessionProcessDef => ({
sessionProcessId,
projectId,
cwd: "/test/path",
abortController: new AbortController(),
setNextMessage: () => {},
});
// Helper function to create mock new task definition
const createMockNewTaskDef = (taskId: string): CCTask.NewClaudeCodeTaskDef => ({
type: "new",
taskId,
});
// Helper function to create mock continue task definition
const createMockContinueTaskDef = (
taskId: string,
sessionId: string,
baseSessionId: string,
): CCTask.ContinueClaudeCodeTaskDef => ({
type: "continue",
taskId,
sessionId,
baseSessionId,
});
// Helper function to create mock init context
const createMockInitContext = (sessionId: string): InitMessageContext => ({
initMessage: {
type: "system",
session_id: sessionId,
} as SDKSystemMessage,
});
// Helper function to create mock result message
const createMockResultMessage = (sessionId: string): SDKResultMessage =>
({
type: "result",
session_id: sessionId,
result: {},
}) as SDKResultMessage;
// Mock EventBus for testing
const MockEventBus = Layer.succeed(EventBus, {
emit: <K extends keyof InternalEventDeclaration>(
_eventName: K,
_event: InternalEventDeclaration[K],
) => Effect.void,
on: <K extends keyof InternalEventDeclaration>(
_eventName: K,
_listener: (event: InternalEventDeclaration[K]) => void,
) => Effect.void,
off: <K extends keyof InternalEventDeclaration>(
_eventName: K,
_listener: (event: InternalEventDeclaration[K]) => void,
) => Effect.void,
});
const TestLayer = Layer.provide(
ClaudeCodeSessionProcessService.Live,
MockEventBus,
);
describe("ClaudeCodeSessionProcessService", () => {
describe("startSessionProcess", () => {
it("can start new session process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
const result = yield* service.startSessionProcess({
sessionDef,
taskDef,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("pending");
expect(result.sessionProcess.def.sessionProcessId).toBe("process-1");
expect(result.task.status).toBe("pending");
expect(result.task.def.taskId).toBe("task-1");
});
it("creates session process with correct task structure", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
const result = yield* service.startSessionProcess({
sessionDef,
taskDef,
});
return { result, taskDef };
});
const { result, taskDef } = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.tasks).toHaveLength(1);
expect(result.sessionProcess.currentTask).toBe(result.task);
expect(result.sessionProcess.currentTask.def).toBe(taskDef);
});
});
describe("getSessionProcess", () => {
it("can retrieve created session process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const process = yield* service.getSessionProcess("process-1");
return process;
});
const process = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(process.def.sessionProcessId).toBe("process-1");
expect(process.type).toBe("pending");
});
it("fails with SessionProcessNotFoundError for non-existent process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip(
service.getSessionProcess("non-existent"),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "SessionProcessNotFoundError",
sessionProcessId: "non-existent",
});
});
});
describe("getSessionProcesses", () => {
it("returns empty array when no processes exist", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const processes = yield* service.getSessionProcesses();
return processes;
});
const processes = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(processes).toHaveLength(0);
});
it("returns all created processes", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef1 = createMockSessionProcessDef("process-1");
const taskDef1 = createMockNewTaskDef("task-1");
const sessionDef2 = createMockSessionProcessDef("process-2");
const taskDef2 = createMockNewTaskDef("task-2");
yield* service.startSessionProcess({
sessionDef: sessionDef1,
taskDef: taskDef1,
});
yield* service.startSessionProcess({
sessionDef: sessionDef2,
taskDef: taskDef2,
});
const processes = yield* service.getSessionProcesses();
return processes;
});
const processes = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(processes).toHaveLength(2);
expect(processes.map((p) => p.def.sessionProcessId)).toEqual(
expect.arrayContaining(["process-1", "process-2"]),
);
});
});
describe("continueSessionProcess", () => {
it("can continue paused session process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
// Start and progress to paused state
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
// Continue the paused process
const continueTaskDef = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const result = yield* service.continueSessionProcess({
sessionProcessId: "process-1",
taskDef: continueTaskDef,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("pending");
expect(result.task.def.taskId).toBe("task-2");
expect(result.sessionProcess.tasks).toHaveLength(2);
});
it("fails with SessionProcessNotPausedError when process is not paused", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const continueTaskDef = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const result = yield* Effect.flip(
service.continueSessionProcess({
sessionProcessId: "process-1",
taskDef: continueTaskDef,
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "SessionProcessNotPausedError",
sessionProcessId: "process-1",
});
});
it("fails with SessionProcessNotFoundError for non-existent process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const continueTaskDef = createMockContinueTaskDef(
"task-1",
"session-1",
"session-1",
);
const result = yield* Effect.flip(
service.continueSessionProcess({
sessionProcessId: "non-existent",
taskDef: continueTaskDef,
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "SessionProcessNotFoundError",
sessionProcessId: "non-existent",
});
});
});
describe("toNotInitializedState", () => {
it("can transition from pending to not_initialized", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const result = yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("not_initialized");
expect(result.sessionProcess.rawUserMessage).toBe("test message");
expect(result.task.status).toBe("running");
});
it("fails with IllegalStateChangeError when not in pending state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
// Try to transition again
const result = yield* Effect.flip(
service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message 2",
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "IllegalStateChangeError",
from: "not_initialized",
to: "not_initialized",
});
});
});
describe("toInitializedState", () => {
it("can transition from not_initialized to initialized", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
const initContext = createMockInitContext("session-1");
const result = yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("initialized");
expect(result.sessionProcess.sessionId).toBe("session-1");
expect(result.sessionProcess.initContext).toBeDefined();
});
it("fails with IllegalStateChangeError when not in not_initialized state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const initContext = createMockInitContext("session-1");
const result = yield* Effect.flip(
service.toInitializedState({
sessionProcessId: "process-1",
initContext,
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "IllegalStateChangeError",
from: "pending",
to: "initialized",
});
});
});
describe("toPausedState", () => {
it("can transition from file_created to paused", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
const resultMessage = createMockResultMessage("session-1");
const result = yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("paused");
expect(result.sessionProcess.sessionId).toBe("session-1");
});
it("marks current task as completed", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
const process = yield* service.getSessionProcess("process-1");
return process;
});
const process = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
const completedTask = process.tasks.find(
(t) => t.def.taskId === "task-1",
);
expect(completedTask?.status).toBe("completed");
if (completedTask?.status === "completed") {
expect(completedTask.sessionId).toBe("session-1");
}
});
it("fails with IllegalStateChangeError when not in file_created state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const result = yield* Effect.flip(
service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "IllegalStateChangeError",
from: "pending",
to: "paused",
});
});
});
describe("toCompletedState", () => {
it("can transition to completed state from any state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
const result = yield* service.toCompletedState({
sessionProcessId: "process-1",
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("completed");
});
it("marks current task as completed when no error", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
const result = yield* service.toCompletedState({
sessionProcessId: "process-1",
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.task?.status).toBe("completed");
});
it("marks current task as failed when error is provided", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
const error = new Error("Test error");
const result = yield* service.toCompletedState({
sessionProcessId: "process-1",
error,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.task?.status).toBe("failed");
if (result.task?.status === "failed") {
expect(result.task.error).toBeInstanceOf(Error);
}
});
});
describe("getTask", () => {
it("can retrieve task by taskId", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const result = yield* service.getTask("task-1");
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.task.def.taskId).toBe("task-1");
expect(result.sessionProcess.def.sessionProcessId).toBe("process-1");
});
it("fails with TaskNotFoundError for non-existent task", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip(service.getTask("non-existent-task"));
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "TaskNotFoundError",
taskId: "non-existent-task",
});
});
});
describe("state transitions flow", () => {
it("can complete full lifecycle: pending -> not_initialized -> initialized -> file_created -> paused", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
const startResult = yield* service.startSessionProcess({
sessionDef,
taskDef,
});
expect(startResult.sessionProcess.type).toBe("pending");
const notInitResult = yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
expect(notInitResult.sessionProcess.type).toBe("not_initialized");
const initResult = yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
expect(initResult.sessionProcess.type).toBe("initialized");
const fileCreatedResult = yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
expect(fileCreatedResult.sessionProcess.type).toBe("file_created");
const pausedResult = yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
expect(pausedResult.sessionProcess.type).toBe("paused");
return pausedResult;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("paused");
expect(result.sessionProcess.sessionId).toBe("session-1");
});
it("can continue paused process and complete another task", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
// First task lifecycle
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef1 = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef: taskDef1,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message 1",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
// Continue with second task
const taskDef2 = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const continueResult = yield* service.continueSessionProcess({
sessionProcessId: "process-1",
taskDef: taskDef2,
});
expect(continueResult.sessionProcess.type).toBe("pending");
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message 2",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
const finalResult = yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
return finalResult;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("paused");
expect(result.sessionProcess.tasks).toHaveLength(2);
expect(
result.sessionProcess.tasks.filter((t) => t.status === "completed"),
).toHaveLength(2);
});
});
});

View File

@@ -0,0 +1,500 @@
import type { SDKResultMessage } from "@anthropic-ai/claude-code";
import { Context, Data, Effect, Layer, Ref } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { EventBus } from "../../events/services/EventBus";
import * as CCSessionProcess from "../models/CCSessionProcess";
import type * as CCTask from "../models/ClaudeCodeTask";
import type { InitMessageContext } from "../types";
class SessionProcessNotFoundError extends Data.TaggedError(
"SessionProcessNotFoundError",
)<{
sessionProcessId: string;
}> {}
class SessionProcessNotPausedError extends Data.TaggedError(
"SessionProcessNotPausedError",
)<{
sessionProcessId: string;
}> {}
class SessionProcessAlreadyAliveError extends Data.TaggedError(
"SessionProcessAlreadyAliveError",
)<{
sessionProcessId: string;
aliveTaskId: string;
aliveTaskSessionId?: string;
}> {}
class IllegalStateChangeError extends Data.TaggedError(
"IllegalStateChangeError",
)<{
from: CCSessionProcess.CCSessionProcessState["type"];
to: CCSessionProcess.CCSessionProcessState["type"];
}> {}
class TaskNotFoundError extends Data.TaggedError("TaskNotFoundError")<{
taskId: string;
}> {}
const LayerImpl = Effect.gen(function* () {
const processesRef = yield* Ref.make<
CCSessionProcess.CCSessionProcessState[]
>([]);
const eventBus = yield* EventBus;
const startSessionProcess = (options: {
sessionDef: CCSessionProcess.CCSessionProcessDef;
taskDef: CCTask.NewClaudeCodeTaskDef | CCTask.ResumeClaudeCodeTaskDef;
}) => {
const { sessionDef, taskDef } = options;
return Effect.gen(function* () {
const task: CCTask.PendingClaudeCodeTaskState = {
def: taskDef,
status: "pending",
};
const newProcess: CCSessionProcess.CCSessionProcessState = {
def: sessionDef,
type: "pending",
tasks: [task],
currentTask: task,
};
yield* Ref.update(processesRef, (processes) => [
...processes,
newProcess,
]);
return {
sessionProcess: newProcess,
task,
};
});
};
const continueSessionProcess = (options: {
sessionProcessId: string;
taskDef: CCTask.ContinueClaudeCodeTaskDef;
}) => {
const { sessionProcessId } = options;
return Effect.gen(function* () {
const process = yield* getSessionProcess(sessionProcessId);
if (process.type !== "paused") {
return yield* Effect.fail(
new SessionProcessNotPausedError({
sessionProcessId,
}),
);
}
const [firstAliveTask] = CCSessionProcess.getAliveTasks(process);
if (firstAliveTask !== undefined) {
return yield* Effect.fail(
new SessionProcessAlreadyAliveError({
sessionProcessId,
aliveTaskId: firstAliveTask.def.taskId,
aliveTaskSessionId:
firstAliveTask.def.sessionId ?? firstAliveTask.sessionId,
}),
);
}
const newTask: CCTask.PendingClaudeCodeTaskState = {
def: options.taskDef,
status: "pending",
};
const newProcess: CCSessionProcess.CCSessionProcessPendingState = {
def: process.def,
type: "pending",
tasks: [...process.tasks, newTask],
currentTask: newTask,
};
yield* Ref.update(processesRef, (processes) => {
return processes.map((p) =>
p.def.sessionProcessId === sessionProcessId ? newProcess : p,
);
});
return {
sessionProcess: newProcess,
task: newTask,
};
});
};
const getSessionProcess = (sessionProcessId: string) => {
return Effect.gen(function* () {
const processes = yield* Ref.get(processesRef);
const result = processes.find(
(p) => p.def.sessionProcessId === sessionProcessId,
);
if (result === undefined) {
return yield* Effect.fail(
new SessionProcessNotFoundError({ sessionProcessId }),
);
}
return result;
});
};
const getSessionProcesses = () => {
return Effect.gen(function* () {
const processes = yield* Ref.get(processesRef);
return processes;
});
};
const getTask = (taskId: string) => {
return Effect.gen(function* () {
const processes = yield* Ref.get(processesRef);
const result = processes
.flatMap((p) => {
const found = p.tasks.find((t) => t.def.taskId === taskId);
if (found === undefined) {
return [];
}
return [
{
sessionProcess: p,
task: found,
},
];
})
.at(0);
if (result === undefined) {
return yield* Effect.fail(new TaskNotFoundError({ taskId }));
}
return result;
});
};
const dangerouslyChangeProcessState = <
T extends CCSessionProcess.CCSessionProcessState,
>(options: {
sessionProcessId: string;
nextState: T;
}) => {
const { sessionProcessId, nextState } = options;
return Effect.gen(function* () {
const processes = yield* Ref.get(processesRef);
const targetProcess = processes.find(
(p) => p.def.sessionProcessId === sessionProcessId,
);
const currentStatus = targetProcess?.type;
const updatedProcesses = processes.map((p) =>
p.def.sessionProcessId === sessionProcessId ? nextState : p,
);
yield* Ref.set(processesRef, updatedProcesses);
if (currentStatus !== nextState.type) {
yield* eventBus.emit("sessionProcessChanged", {
processes: updatedProcesses
.filter(CCSessionProcess.isPublic)
.map((process) => ({
id: process.def.sessionProcessId,
projectId: process.def.projectId,
sessionId: process.sessionId,
status: process.type === "paused" ? "paused" : "running",
})),
changed: nextState,
});
}
console.log(
`sessionProcessStateChanged(${sessionProcessId}): ${targetProcess?.type} -> ${nextState.type}`,
);
return nextState;
});
};
const changeTaskState = <T extends CCTask.ClaudeCodeTaskState>(options: {
sessionProcessId: string;
taskId: string;
nextTask: T;
}) => {
const { sessionProcessId, taskId, nextTask } = options;
return Effect.gen(function* () {
const { task } = yield* getTask(taskId);
yield* Ref.update(processesRef, (processes) => {
return processes.map((p) =>
p.def.sessionProcessId === sessionProcessId
? {
...p,
tasks: p.tasks.map((t) =>
t.def.taskId === task.def.taskId ? { ...nextTask } : t,
),
}
: p,
);
});
const updated = yield* getTask(taskId);
if (updated === undefined) {
throw new Error("Unreachable: updatedProcess is undefined");
}
return updated.task as T;
});
};
const toNotInitializedState = (options: {
sessionProcessId: string;
rawUserMessage: string;
}) => {
const { sessionProcessId, rawUserMessage } = options;
return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId);
if (currentProcess.type !== "pending") {
return yield* Effect.fail(
new IllegalStateChangeError({
from: currentProcess.type,
to: "not_initialized",
}),
);
}
const newTask = yield* changeTaskState({
sessionProcessId,
taskId: currentProcess.currentTask.def.taskId,
nextTask: {
status: "running",
def: currentProcess.currentTask.def,
},
});
const newProcess = yield* dangerouslyChangeProcessState({
sessionProcessId,
nextState: {
type: "not_initialized",
def: currentProcess.def,
tasks: currentProcess.tasks,
currentTask: newTask,
rawUserMessage,
},
});
return {
sessionProcess: newProcess,
task: newTask,
};
});
};
const toInitializedState = (options: {
sessionProcessId: string;
initContext: InitMessageContext;
}) => {
const { sessionProcessId, initContext } = options;
return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId);
if (currentProcess.type !== "not_initialized") {
return yield* Effect.fail(
new IllegalStateChangeError({
from: currentProcess.type,
to: "initialized",
}),
);
}
const newProcess = yield* dangerouslyChangeProcessState({
sessionProcessId,
nextState: {
type: "initialized",
def: currentProcess.def,
tasks: currentProcess.tasks,
currentTask: currentProcess.currentTask,
sessionId: initContext.initMessage.session_id,
rawUserMessage: currentProcess.rawUserMessage,
initContext: initContext,
},
});
return {
sessionProcess: newProcess,
};
});
};
const toFileCreatedState = (options: { sessionProcessId: string }) => {
const { sessionProcessId } = options;
return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId);
if (currentProcess.type !== "initialized") {
return yield* Effect.fail(
new IllegalStateChangeError({
from: currentProcess.type,
to: "file_created",
}),
);
}
const newProcess = yield* dangerouslyChangeProcessState({
sessionProcessId,
nextState: {
type: "file_created",
def: currentProcess.def,
tasks: currentProcess.tasks,
currentTask: currentProcess.currentTask,
sessionId: currentProcess.sessionId,
rawUserMessage: currentProcess.rawUserMessage,
initContext: currentProcess.initContext,
},
});
return {
sessionProcess: newProcess,
};
});
};
const toPausedState = (options: {
sessionProcessId: string;
resultMessage: SDKResultMessage;
}) => {
const { sessionProcessId, resultMessage } = options;
return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId);
if (currentProcess.type !== "file_created") {
return yield* Effect.fail(
new IllegalStateChangeError({
from: currentProcess.type,
to: "paused",
}),
);
}
const newTask = yield* changeTaskState({
sessionProcessId,
taskId: currentProcess.currentTask.def.taskId,
nextTask: {
status: "completed",
def: currentProcess.currentTask.def,
sessionId: resultMessage.session_id,
},
});
const newProcess = yield* dangerouslyChangeProcessState({
sessionProcessId,
nextState: {
type: "paused",
def: currentProcess.def,
tasks: currentProcess.tasks.map((t) =>
t.def.taskId === newTask.def.taskId ? newTask : t,
),
sessionId: currentProcess.sessionId,
},
});
return {
sessionProcess: newProcess,
};
});
};
const toCompletedState = (options: {
sessionProcessId: string;
error?: unknown;
}) => {
const { sessionProcessId, error } = options;
return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId);
const currentTask =
currentProcess.type === "not_initialized" ||
currentProcess.type === "initialized" ||
currentProcess.type === "file_created"
? currentProcess.currentTask
: undefined;
const newTask =
currentTask !== undefined
? error !== undefined
? ({
status: "failed",
def: currentTask.def,
error,
} as const)
: ({
status: "completed",
def: currentTask.def,
sessionId: currentProcess.sessionId,
} as const)
: undefined;
if (newTask !== undefined) {
yield* changeTaskState({
sessionProcessId,
taskId: newTask.def.taskId,
nextTask: newTask,
});
}
const newProcess = yield* dangerouslyChangeProcessState({
sessionProcessId,
nextState: {
type: "completed",
def: currentProcess.def,
tasks:
newTask !== undefined
? currentProcess.tasks.map((t) =>
t.def.taskId === newTask.def.taskId ? newTask : t,
)
: currentProcess.tasks,
sessionId: currentProcess.sessionId,
},
});
return {
sessionProcess: newProcess,
task: newTask,
};
});
};
return {
// session
startSessionProcess,
continueSessionProcess,
toNotInitializedState,
toInitializedState,
toFileCreatedState,
toPausedState,
toCompletedState,
dangerouslyChangeProcessState,
getSessionProcesses,
getSessionProcess,
// task
getTask,
changeTaskState,
};
});
export type IClaudeCodeSessionProcessService = InferEffect<typeof LayerImpl>;
export class ClaudeCodeSessionProcessService extends Context.Tag(
"ClaudeCodeSessionProcessService",
)<ClaudeCodeSessionProcessService, IClaudeCodeSessionProcessService>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,5 @@
import type { SDKSystemMessage } from "@anthropic-ai/claude-code";
export type InitMessageContext = {
initMessage: SDKSystemMessage;
};