mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-26 00:44:25 +01:00
refactor: move directories
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "-"),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
301
src/server/core/claude-code/functions/parseCommandXml.test.ts
Normal file
301
src/server/core/claude-code/functions/parseCommandXml.test.ts
Normal 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 & demo'</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("git commit -m 'test & 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
74
src/server/core/claude-code/functions/parseCommandXml.ts
Normal file
74
src/server/core/claude-code/functions/parseCommandXml.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
378
src/server/core/claude-code/functions/parseJsonl.test.ts
Normal file
378
src/server/core/claude-code/functions/parseJsonl.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/server/core/claude-code/functions/parseJsonl.ts
Normal file
23
src/server/core/claude-code/functions/parseJsonl.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
121
src/server/core/claude-code/models/CCSessionProcess.ts
Normal file
121
src/server/core/claude-code/models/CCSessionProcess.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
94
src/server/core/claude-code/models/ClaudeCode.test.ts
Normal file
94
src/server/core/claude-code/models/ClaudeCode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/server/core/claude-code/models/ClaudeCode.ts
Normal file
81
src/server/core/claude-code/models/ClaudeCode.ts
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
59
src/server/core/claude-code/models/ClaudeCodeTask.ts
Normal file
59
src/server/core/claude-code/models/ClaudeCodeTask.ts
Normal 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;
|
||||
84
src/server/core/claude-code/models/ClaudeCodeVersion.test.ts
Normal file
84
src/server/core/claude-code/models/ClaudeCodeVersion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/server/core/claude-code/models/ClaudeCodeVersion.ts
Normal file
47
src/server/core/claude-code/models/ClaudeCodeVersion.ts
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
5
src/server/core/claude-code/types.ts
Normal file
5
src/server/core/claude-code/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { SDKSystemMessage } from "@anthropic-ai/claude-code";
|
||||
|
||||
export type InitMessageContext = {
|
||||
initMessage: SDKSystemMessage;
|
||||
};
|
||||
Reference in New Issue
Block a user