feat: improve interactivity by predict sessions

This commit is contained in:
d-kimsuon
2025-10-15 01:18:14 +09:00
parent c7d89d47cd
commit 0259e71b44
15 changed files with 186 additions and 84 deletions

22
.vscode/settings.json vendored
View File

@@ -7,7 +7,27 @@
"biome.enabled": true, "biome.enabled": true,
// autofix // autofix
"editor.formatOnSave": false, "editor.formatOnSave": false,
"[typescript][typescriptreact][javascript][javascriptreact][json][jsonc][json][yaml]": { "[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },

View File

@@ -32,13 +32,7 @@ export const useNewChatMutation = (
}, },
onSuccess: async (response) => { onSuccess: async (response) => {
onSuccess?.(); onSuccess?.();
router.push( router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
`/projects/${projectId}/sessions/${response.sessionId}` +
response.userMessageId !==
undefined
? `#message-${response.userMessageId}`
: "",
);
}, },
}); });
}; };
@@ -70,9 +64,7 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => {
}, },
onSuccess: async (response) => { onSuccess: async (response) => {
if (sessionId !== response.sessionId) { if (sessionId !== response.sessionId) {
router.push( router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
} }
}, },
}); });

View File

@@ -27,13 +27,13 @@ export const initialize = async (deps: {
console.log("Initializing sessions cache"); console.log("Initializing sessions cache");
const results = await Promise.all( const results = await Promise.all(
projects.map((project) => deps.sessionRepository.getSessions(project.id)) projects.map((project) => deps.sessionRepository.getSessions(project.id)),
); );
console.log( console.log(
`${results.reduce( `${results.reduce(
(s, { sessions }) => s + sessions.length, (s, { sessions }) => s + sessions.length,
0 0,
)} sessions cache initialized` )} sessions cache initialized`,
); );
} catch { } catch {
// do nothing // do nothing

View File

@@ -337,7 +337,6 @@ export const routes = async (app: HonoAppType) => {
return c.json({ return c.json({
taskId: task.id, taskId: task.id,
sessionId: task.sessionId, sessionId: task.sessionId,
userMessageId: task.userMessageId,
}); });
}, },
) )
@@ -373,7 +372,6 @@ export const routes = async (app: HonoAppType) => {
return c.json({ return c.json({
taskId: task.id, taskId: task.id,
sessionId: task.sessionId, sessionId: task.sessionId,
userMessageId: task.userMessageId,
}); });
}, },
) )
@@ -385,7 +383,6 @@ export const routes = async (app: HonoAppType) => {
id: task.id, id: task.id,
status: task.status, status: task.status,
sessionId: task.sessionId, sessionId: task.sessionId,
userMessageId: task.userMessageId,
}), }),
), ),
}); });

View File

@@ -13,7 +13,7 @@ export class FileCacheStorage<const T> {
public static load<const LoadSchema>( public static load<const LoadSchema>(
key: string, key: string,
schema: z.ZodType<LoadSchema> schema: z.ZodType<LoadSchema>,
) { ) {
const instance = new FileCacheStorage<LoadSchema>(key); const instance = new FileCacheStorage<LoadSchema>(key);

View File

@@ -1,8 +1,6 @@
export class InMemoryCacheStorage<const T> { export class InMemoryCacheStorage<const T> {
private storage = new Map<string, T>(); private storage = new Map<string, T>();
public constructor() {}
public get(key: string) { public get(key: string) {
return this.storage.get(key); return this.storage.get(key);
} }

View File

@@ -23,13 +23,17 @@ export class ClaudeCodeExecutor {
); );
} }
public get features() { public get version() {
return this.claudeCodeVersion?.version;
}
public get availableFeatures() {
return { return {
enableToolApproval: canUseTool:
this.claudeCodeVersion?.greaterThanOrEqual( this.claudeCodeVersion?.greaterThanOrEqual(
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }),
) ?? false, ) ?? false,
extractUuidFromSDKMessage: uuidOnSDKMessage:
this.claudeCodeVersion?.greaterThanOrEqual( this.claudeCodeVersion?.greaterThanOrEqual(
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }),
) ?? false, ) ?? false,
@@ -44,7 +48,7 @@ export class ClaudeCodeExecutor {
options: { options: {
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
...baseOptions, ...baseOptions,
...(this.features.enableToolApproval ? { canUseTool } : {}), ...(this.availableFeatures.canUseTool ? { canUseTool } : {}),
}, },
}); });
} }

View File

@@ -2,6 +2,7 @@ import prexit from "prexit";
import { ulid } from "ulid"; import { ulid } from "ulid";
import type { Config } from "../../config/config"; import type { Config } from "../../config/config";
import { eventBus } from "../events/EventBus"; import { eventBus } from "../events/EventBus";
import { predictSessionsDatabase } from "../session/PredictSessionsDatabase";
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
import { createMessageGenerator } from "./createMessageGenerator"; import { createMessageGenerator } from "./createMessageGenerator";
import type { import type {
@@ -168,9 +169,20 @@ export class ClaudeCodeTaskController {
); );
if (existingTask) { if (existingTask) {
console.log(
`Alive task for session(id=${currentSession.sessionId}) continued.`,
);
const result = await this.continueTask(existingTask, message); const result = await this.continueTask(existingTask, message);
return result; return result;
} else { } else {
if (currentSession.sessionId === undefined) {
console.log(`New task started.`);
} else {
console.log(
`New task started for existing session(id=${currentSession.sessionId}).`,
);
}
const result = await this.startTask(currentSession, message); const result = await this.startTask(currentSession, message);
return result; return result;
} }
@@ -188,7 +200,7 @@ export class ClaudeCodeTaskController {
projectId: string; projectId: string;
sessionId?: string; sessionId?: string;
}, },
message: string, userMessage: string,
) { ) {
const { const {
generateMessages, generateMessages,
@@ -196,7 +208,7 @@ export class ClaudeCodeTaskController {
setFirstMessagePromise, setFirstMessagePromise,
resolveFirstMessage, resolveFirstMessage,
awaitFirstMessage, awaitFirstMessage,
} = createMessageGenerator(message); } = createMessageGenerator(userMessage);
const task: PendingClaudeCodeTask = { const task: PendingClaudeCodeTask = {
status: "pending", status: "pending",
@@ -252,43 +264,62 @@ export class ClaudeCodeTaskController {
}); });
} }
// 初回の system message だとまだ history ファイルが作成されていないので if (
if (message.type === "user" || message.type === "assistant") { message.type === "system" &&
// 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある message.subtype === "init" &&
if (!resolved) { currentSession.sessionId === undefined
console.log( ) {
"[DEBUG startTask] 10. Resolving task for first time", // because it takes time for the Claude Code file to be updated, simulate the message
); predictSessionsDatabase.createPredictSession({
id: message.session_id,
const runningTask: RunningClaudeCodeTask = { jsonlFilePath: message.session_id,
status: "running", conversations: [
id: task.id, {
projectId: task.projectId, type: "user",
cwd: task.cwd, message: {
generateMessages: task.generateMessages, role: "user",
setNextMessage: task.setNextMessage, content: userMessage,
resolveFirstMessage: task.resolveFirstMessage, },
setFirstMessagePromise: task.setFirstMessagePromise, isSidechain: false,
awaitFirstMessage: task.awaitFirstMessage, userType: "external",
onMessageHandlers: task.onMessageHandlers, cwd: message.cwd,
userMessageId: message.uuid, sessionId: message.session_id,
sessionId: message.session_id, version: this.claudeCode.version?.toString() ?? "unknown",
abortController: abortController, uuid: message.uuid,
}; timestamp: new Date().toISOString(),
this.tasks.push(runningTask); parentUuid: null,
console.log( },
"[DEBUG startTask] 11. About to call aliveTaskResolve", ],
); meta: {
aliveTaskResolve(runningTask); firstCommand: null,
resolved = true; lastModifiedAt: new Date().toISOString(),
console.log( messageCount: 0,
"[DEBUG startTask] 12. aliveTaskResolve called, resolved=true", },
); });
}
resolveFirstMessage();
} }
if (!resolved) {
const runningTask: RunningClaudeCodeTask = {
status: "running",
id: task.id,
projectId: task.projectId,
cwd: task.cwd,
generateMessages: task.generateMessages,
setNextMessage: task.setNextMessage,
resolveFirstMessage: task.resolveFirstMessage,
setFirstMessagePromise: task.setFirstMessagePromise,
awaitFirstMessage: task.awaitFirstMessage,
onMessageHandlers: task.onMessageHandlers,
sessionId: message.session_id,
abortController: abortController,
};
this.tasks.push(runningTask);
aliveTaskResolve(runningTask);
resolved = true;
}
resolveFirstMessage();
await Promise.all( await Promise.all(
task.onMessageHandlers.map(async (onMessageHandler) => { task.onMessageHandlers.map(async (onMessageHandler) => {
await onMessageHandler(message); await onMessageHandler(message);
@@ -302,6 +333,7 @@ export class ClaudeCodeTaskController {
}); });
resolved = true; resolved = true;
setFirstMessagePromise(); setFirstMessagePromise();
predictSessionsDatabase.deletePredictSession(currentTask.sessionId);
} }
} }
@@ -372,7 +404,6 @@ export class ClaudeCodeTaskController {
awaitFirstMessage: task.awaitFirstMessage, awaitFirstMessage: task.awaitFirstMessage,
onMessageHandlers: task.onMessageHandlers, onMessageHandlers: task.onMessageHandlers,
baseSessionId: task.baseSessionId, baseSessionId: task.baseSessionId,
userMessageId: task.userMessageId,
}); });
} }

View File

@@ -43,6 +43,10 @@ export class ClaudeCodeVersion {
return this.version.patch; return this.version.patch;
} }
public toString() {
return `${this.major}.${this.minor}.${this.patch}`;
}
public equals(other: ClaudeCodeVersion) { public equals(other: ClaudeCodeVersion) {
return ( return (
this.version.major === other.version.major && this.version.major === other.version.major &&

View File

@@ -20,21 +20,18 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & {
export type RunningClaudeCodeTask = BaseClaudeCodeTask & { export type RunningClaudeCodeTask = BaseClaudeCodeTask & {
status: "running"; status: "running";
sessionId: string; sessionId: string;
userMessageId: string | undefined;
abortController: AbortController; abortController: AbortController;
}; };
export type PausedClaudeCodeTask = BaseClaudeCodeTask & { export type PausedClaudeCodeTask = BaseClaudeCodeTask & {
status: "paused"; status: "paused";
sessionId: string; sessionId: string;
userMessageId: string | undefined;
abortController: AbortController; abortController: AbortController;
}; };
type CompletedClaudeCodeTask = BaseClaudeCodeTask & { type CompletedClaudeCodeTask = BaseClaudeCodeTask & {
status: "completed"; status: "completed";
sessionId: string; sessionId: string;
userMessageId: string | undefined;
abortController: AbortController; abortController: AbortController;
resolveFirstMessage: () => void; resolveFirstMessage: () => void;
}; };
@@ -56,7 +53,7 @@ export type AliveClaudeCodeTask = RunningClaudeCodeTask | PausedClaudeCodeTask;
export type SerializableAliveTask = Pick< export type SerializableAliveTask = Pick<
AliveClaudeCodeTask, AliveClaudeCodeTask,
"id" | "status" | "sessionId" | "userMessageId" "id" | "status" | "sessionId"
>; >;
export type PermissionRequest = { export type PermissionRequest = {

View File

@@ -1,3 +1,5 @@
import { dirname } from "node:path";
export const encodeProjectId = (fullPath: string) => { export const encodeProjectId = (fullPath: string) => {
return Buffer.from(fullPath).toString("base64url"); return Buffer.from(fullPath).toString("base64url");
}; };
@@ -5,3 +7,7 @@ export const encodeProjectId = (fullPath: string) => {
export const decodeProjectId = (id: string) => { export const decodeProjectId = (id: string) => {
return Buffer.from(id, "base64url").toString("utf-8"); return Buffer.from(id, "base64url").toString("utf-8");
}; };
export const encodeProjectIdFromSessionFilePath = (sessionFilePath: string) => {
return encodeProjectId(dirname(sessionFilePath));
};

View File

@@ -3,15 +3,15 @@ import { readdir, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path"; import { basename, resolve } from "node:path";
import { z } from "zod"; import { z } from "zod";
import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage";
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
import { parseJsonl } from "../parseJsonl"; import { parseJsonl } from "../parseJsonl";
import type { ProjectMeta } from "../types"; import type { ProjectMeta } from "../types";
import { decodeProjectId } from "./id"; import { decodeProjectId } from "./id";
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
class ProjectMetaStorage { class ProjectMetaStorage {
private projectPathCache = FileCacheStorage.load( private projectPathCache = FileCacheStorage.load(
"project-path-cache", "project-path-cache",
z.string().nullable() z.string().nullable(),
); );
private projectMetaCache = new InMemoryCacheStorage<ProjectMeta>(); private projectMetaCache = new InMemoryCacheStorage<ProjectMeta>();
@@ -31,7 +31,7 @@ class ProjectMetaStorage {
({ ({
fullPath: resolve(claudeProjectPath, d.name), fullPath: resolve(claudeProjectPath, d.name),
stats: statSync(resolve(claudeProjectPath, d.name)), stats: statSync(resolve(claudeProjectPath, d.name)),
} as const) }) as const,
) )
.sort((a, b) => { .sort((a, b) => {
return a.stats.mtime.getTime() - b.stats.mtime.getTime(); return a.stats.mtime.getTime() - b.stats.mtime.getTime();
@@ -70,7 +70,7 @@ class ProjectMetaStorage {
} }
private async extractProjectPathFromJsonl( private async extractProjectPathFromJsonl(
filePath: string filePath: string,
): Promise<string | null> { ): Promise<string | null> {
const cached = this.projectPathCache.get(filePath); const cached = this.projectPathCache.get(filePath);
if (cached !== undefined) { if (cached !== undefined) {

View File

@@ -0,0 +1,34 @@
import { encodeProjectIdFromSessionFilePath } from "../project/id";
import type { Session, SessionDetail } from "../types";
/**
* For interactively experience, handle sessions not already persisted to the filesystem.
*/
class PredictSessionsDatabase {
private storage = new Map<string, SessionDetail>();
public getPredictSessions(projectId: string): Session[] {
return Array.from(this.storage.values()).filter(
({ jsonlFilePath }) =>
encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId,
);
}
public getPredictSession(sessionId: string): SessionDetail {
const session = this.storage.get(sessionId);
if (!session) {
throw new Error("Session not found");
}
return session;
}
public createPredictSession(session: SessionDetail) {
this.storage.set(session.id, session);
}
public deletePredictSession(sessionId: string) {
this.storage.delete(sessionId);
}
}
export const predictSessionsDatabase = new PredictSessionsDatabase();

View File

@@ -1,9 +1,11 @@
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { parseJsonl } from "../parseJsonl"; import { parseJsonl } from "../parseJsonl";
import { decodeProjectId } from "../project/id"; import { decodeProjectId } from "../project/id";
import type { Session, SessionDetail } from "../types"; import type { Session, SessionDetail } from "../types";
import { decodeSessionId, encodeSessionId } from "./id"; import { decodeSessionId, encodeSessionId } from "./id";
import { predictSessionsDatabase } from "./PredictSessionsDatabase";
import { sessionMetaStorage } from "./sessionMetaStorage"; import { sessionMetaStorage } from "./sessionMetaStorage";
const getTime = (date: string | null) => { const getTime = (date: string | null) => {
@@ -19,6 +21,17 @@ export class SessionRepository {
session: SessionDetail; session: SessionDetail;
}> { }> {
const sessionPath = decodeSessionId(projectId, sessionId); const sessionPath = decodeSessionId(projectId, sessionId);
if (!existsSync(sessionPath)) {
const predictSession =
predictSessionsDatabase.getPredictSession(sessionId);
if (predictSession) {
return {
session: predictSession,
};
}
throw new Error("Session not found");
}
const content = await readFile(sessionPath, "utf-8"); const content = await readFile(sessionPath, "utf-8");
const conversations = parseJsonl(content); const conversations = parseJsonl(content);
@@ -53,9 +66,16 @@ export class SessionRepository {
), ),
})), })),
); );
const sessionMap = new Map<string, Session>(
sessions.map((session) => [session.id, session]),
);
const predictSessions = predictSessionsDatabase
.getPredictSessions(projectId)
.filter((session) => !sessionMap.has(session.id));
return { return {
sessions: sessions.sort((a, b) => { sessions: [...predictSessions, ...sessions].sort((a, b) => {
return ( return (
getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt)
); );

View File

@@ -1,16 +1,15 @@
import { statSync } from "node:fs"; import { statSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage";
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
import { import {
type ParsedCommand, type ParsedCommand,
parseCommandXml, parseCommandXml,
parsedCommandSchema, parsedCommandSchema,
} from "../parseCommandXml"; } from "../parseCommandXml";
import { parseJsonl } from "../parseJsonl"; import { parseJsonl } from "../parseJsonl";
import { sessionMetaSchema } from "../schema";
import type { SessionMeta } from "../types"; import type { SessionMeta } from "../types";
import { decodeSessionId } from "./id"; import { decodeSessionId } from "./id";
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
const ignoreCommands = [ const ignoreCommands = [
"/clear", "/clear",
@@ -24,13 +23,13 @@ const ignoreCommands = [
class SessionMetaStorage { class SessionMetaStorage {
private firstCommandCache = FileCacheStorage.load( private firstCommandCache = FileCacheStorage.load(
"first-command-cache", "first-command-cache",
parsedCommandSchema parsedCommandSchema,
); );
private sessionMetaCache = new InMemoryCacheStorage<SessionMeta>(); private sessionMetaCache = new InMemoryCacheStorage<SessionMeta>();
public async getSessionMeta( public async getSessionMeta(
projectId: string, projectId: string,
sessionId: string sessionId: string,
): Promise<SessionMeta> { ): Promise<SessionMeta> {
const cached = this.sessionMetaCache.get(sessionId); const cached = this.sessionMetaCache.get(sessionId);
if (cached !== undefined) { if (cached !== undefined) {
@@ -60,7 +59,7 @@ class SessionMetaStorage {
private getFirstCommand = ( private getFirstCommand = (
jsonlFilePath: string, jsonlFilePath: string,
lines: string[] lines: string[],
): ParsedCommand | null => { ): ParsedCommand | null => {
const cached = this.firstCommandCache.get(jsonlFilePath); const cached = this.firstCommandCache.get(jsonlFilePath);
if (cached !== undefined) { if (cached !== undefined) {
@@ -80,14 +79,14 @@ class SessionMetaStorage {
conversation === null conversation === null
? null ? null
: typeof conversation.message.content === "string" : typeof conversation.message.content === "string"
? conversation.message.content ? conversation.message.content
: (() => { : (() => {
const firstContent = conversation.message.content.at(0); const firstContent = conversation.message.content.at(0);
if (firstContent === undefined) return null; if (firstContent === undefined) return null;
if (typeof firstContent === "string") return firstContent; if (typeof firstContent === "string") return firstContent;
if (firstContent.type === "text") return firstContent.text; if (firstContent.type === "text") return firstContent.text;
return null; return null;
})(); })();
if (firstUserText === null) { if (firstUserText === null) {
continue; continue;