refactor: firstCommand

This commit is contained in:
d-kimsuon
2025-10-18 03:39:12 +09:00
parent e45a841656
commit 45ebfad36a
20 changed files with 190 additions and 187 deletions

View File

@@ -1,7 +1,6 @@
import { TaskExecutor } from "../utils/TaskExecutor";
import { errorPagesCapture } from "./error-pages";
import { homeCapture } from "./home";
import { projectDetailCapture } from "./project-detail";
import { projectsCapture } from "./projects";
import { sessionDetailCapture } from "./session-detail";
@@ -17,7 +16,6 @@ const tasks = [
...homeCapture.tasks,
...errorPagesCapture.tasks,
...projectsCapture.tasks,
...projectDetailCapture.tasks,
...sessionDetailCapture.tasks,
];

View File

@@ -1,32 +0,0 @@
import { projectIds } from "../config";
import { defineCapture } from "../utils/defineCapture";
export const projectDetailCapture = defineCapture({
href: `projects/${projectIds.sampleProject}`,
cases: [
{
name: "filters-expanded",
setup: async (page) => {
const filterToggle = page.locator(
'[data-testid="expand-filter-settings-button"]',
);
if (await filterToggle.isVisible()) {
await filterToggle.click();
await page.waitForTimeout(300);
} else {
throw new Error("Filter settings button not found");
}
},
},
{
name: "new-chat-modal",
setup: async (page) => {
const newChatButton = page.locator('[data-testid="new-chat"]');
if (await newChatButton.isVisible()) {
await newChatButton.click();
await page.waitForTimeout(300);
}
},
},
],
});

View File

@@ -1,6 +1,6 @@
import type { ParsedCommand } from "../../../../server/core/claude-code/functions/parseCommandXml";
import type { ParsedUserMessage } from "../../../../server/core/claude-code/functions/parseUserMessage";
export const firstCommandToTitle = (firstCommand: ParsedCommand) => {
export const firstUserMessageToTitle = (firstCommand: ParsedUserMessage) => {
switch (firstCommand.kind) {
case "command":
if (firstCommand.commandArgs === undefined) {

View File

@@ -17,7 +17,7 @@ import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
@@ -117,8 +117,8 @@ export const SessionPageContent: FC<{
<MenuIcon className="w-4 h-4" />
</Button>
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
{session.meta.firstUserMessage !== null
? firstUserMessageToTitle(session.meta.firstUserMessage)
: sessionId}
</h1>
</div>

View File

@@ -3,14 +3,14 @@ import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { parseCommandXml } from "../../../../../../../server/core/claude-code/functions/parseCommandXml";
import { parseUserMessage } from "../../../../../../../server/core/claude-code/functions/parseUserMessage";
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
export const UserTextContent: FC<{ text: string; id?: string }> = ({
text,
id,
}) => {
const parsed = parseCommandXml(text);
const parsed = parseUserMessage(text);
if (parsed.kind === "command") {
return (

View File

@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { Session } from "../../../../../../../server/core/types";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
export const SessionsTab: FC<{
@@ -86,8 +86,8 @@ export const SessionsTab: FC<{
{sortedSessions.map((session) => {
const isActive = session.id === currentSessionId;
const title =
session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
session.meta.firstUserMessage !== null
? firstUserMessageToTitle(session.meta.firstUserMessage)
: session.id;
const sessionProcess = sessionProcesses.find(

View File

@@ -1,38 +1,37 @@
import path from "node:path";
import { Path } from "@effect/platform";
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { computeClaudeProjectFilePath } from "./computeClaudeProjectFilePath";
describe("computeClaudeProjectFilePath", () => {
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
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,
claudeProjectsDirPath: TEST_PROJECTS_DIR,
});
const result = await Effect.runPromise(
computeClaudeProjectFilePath({
projectPath,
claudeProjectsDirPath: TEST_PROJECTS_DIR,
}).pipe(Effect.provide(Path.layer)),
);
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,
claudeProjectsDirPath: TEST_PROJECTS_DIR,
});
const result = await Effect.runPromise(
computeClaudeProjectFilePath({
projectPath,
claudeProjectsDirPath: TEST_PROJECTS_DIR,
}).pipe(Effect.provide(Path.layer)),
);
expect(result).toBe(expected);
});

View File

@@ -1,7 +1,7 @@
import { ConversationSchema } from "../../../../lib/conversation-schema";
import type { ErrorJsonl } from "../../types";
import type { ErrorJsonl, ExtendedConversation } from "../../types";
export const parseJsonl = (content: string) => {
export const parseJsonl = (content: string): ExtendedConversation[] => {
const lines = content
.trim()
.split("\n")

View File

@@ -1,10 +1,10 @@
import { parseCommandXml } from "./parseCommandXml";
import { parseUserMessage } from "./parseUserMessage";
describe("parseCommandXml", () => {
describe("command parsing", () => {
it("parses command-name only", () => {
const input = "<command-name>git status</command-name>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -17,7 +17,7 @@ describe("parseCommandXml", () => {
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);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -30,7 +30,7 @@ describe("parseCommandXml", () => {
it("parses command-name with command-message", () => {
const input =
"<command-name>ls</command-name><command-message>Listing files</command-message>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -43,7 +43,7 @@ describe("parseCommandXml", () => {
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);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -56,7 +56,7 @@ describe("parseCommandXml", () => {
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);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -69,7 +69,7 @@ describe("parseCommandXml", () => {
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);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -83,7 +83,7 @@ describe("parseCommandXml", () => {
describe("local-command parsing", () => {
it("parses local-command-stdout", () => {
const input = "<local-command-stdout>output text</local-command-stdout>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "local-command",
@@ -94,7 +94,7 @@ describe("parseCommandXml", () => {
it("parses local-command-stdout with multiline content", () => {
const input =
"<local-command-stdout>line1\nline2\nline3</local-command-stdout>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "local-command",
@@ -105,7 +105,7 @@ describe("parseCommandXml", () => {
it("parses local-command-stdout with whitespace", () => {
const input =
"<local-command-stdout> \n output with spaces \n </local-command-stdout>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
// The regex pattern preserves all whitespace in content
expect(result).toEqual({
@@ -119,7 +119,7 @@ describe("parseCommandXml", () => {
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);
const result = parseUserMessage(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
@@ -131,7 +131,7 @@ describe("parseCommandXml", () => {
describe("fallback to text", () => {
it("returns text when no matching tags found", () => {
const input = "just plain text";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "text",
@@ -141,7 +141,7 @@ describe("parseCommandXml", () => {
it("returns text when tags are not closed properly", () => {
const input = "<command-name>incomplete";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "text",
@@ -151,7 +151,7 @@ describe("parseCommandXml", () => {
it("returns text when tags are mismatched", () => {
const input = "<command-name>test</different-tag>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "text",
@@ -161,7 +161,7 @@ describe("parseCommandXml", () => {
it("returns text with empty string", () => {
const input = "";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "text",
@@ -171,7 +171,7 @@ describe("parseCommandXml", () => {
it("returns text with only unrecognized tags", () => {
const input = "<unknown-tag>content</unknown-tag>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "text",
@@ -184,7 +184,7 @@ describe("parseCommandXml", () => {
it("handles multiple same tags (uses first match)", () => {
const input =
"<command-name>first</command-name><command-name>second</command-name>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
@@ -194,7 +194,7 @@ describe("parseCommandXml", () => {
it("handles empty tag content", () => {
const input = "<command-name></command-name>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -207,7 +207,7 @@ describe("parseCommandXml", () => {
it("handles tags with special characters in content", () => {
const input =
"<command-name>git commit -m 'test &amp; demo'</command-name>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
@@ -217,7 +217,7 @@ describe("parseCommandXml", () => {
it("does not match nested tags (regex limitation)", () => {
const input = "<command-name><nested>inner</nested>outer</command-name>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
// The regex won't match properly nested tags due to [^<]* pattern
expect(result.kind).toBe("text");
@@ -226,7 +226,7 @@ describe("parseCommandXml", () => {
it("handles tags with surrounding text", () => {
const input =
"Some text before <command-name>test</command-name> and after";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -239,7 +239,7 @@ describe("parseCommandXml", () => {
it("handles newlines between tags", () => {
const input =
"<command-name>test</command-name>\n\n<command-args>arg</command-args>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -252,7 +252,7 @@ describe("parseCommandXml", () => {
it("handles very long content", () => {
const longContent = "x".repeat(10000);
const input = `<command-name>${longContent}</command-name>`;
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
@@ -262,7 +262,7 @@ describe("parseCommandXml", () => {
it("handles tags with attributes (not matched)", () => {
const input = '<command-name attr="value">test</command-name>';
const result = parseCommandXml(input);
const result = parseUserMessage(input);
// Tags with attributes won't match because regex expects <tag> not <tag attr="...">
expect(result.kind).toBe("text");
@@ -270,14 +270,14 @@ describe("parseCommandXml", () => {
it("handles self-closing tags (not matched)", () => {
const input = "<command-name />";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result.kind).toBe("text");
});
it("handles Unicode content", () => {
const input = "<command-name>テスト コマンド 🚀</command-name>";
const result = parseCommandXml(input);
const result = parseUserMessage(input);
expect(result).toEqual({
kind: "command",
@@ -290,7 +290,7 @@ describe("parseCommandXml", () => {
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);
const result = parseUserMessage(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {

View File

@@ -7,7 +7,7 @@ const matchSchema = z.object({
content: z.string(),
});
export const parsedCommandSchema = z.union([
export const parsedUserMessageSchema = z.union([
z.object({
kind: z.literal("command"),
commandName: z.string(),
@@ -24,9 +24,9 @@ export const parsedCommandSchema = z.union([
}),
]);
export type ParsedCommand = z.infer<typeof parsedCommandSchema>;
export type ParsedUserMessage = z.infer<typeof parsedUserMessageSchema>;
export const parseCommandXml = (content: string): ParsedCommand => {
export const parseUserMessage = (content: string): ParsedUserMessage => {
const matches = Array.from(content.matchAll(regExp))
.map((match) => matchSchema.safeParse(match.groups))
.filter((result) => result.success)

View File

@@ -41,7 +41,7 @@ const LayerImpl = Effect.gen(function* () {
// Filter sessions based on hideNoUserMessageSession setting
if (userConfig.hideNoUserMessageSession) {
filteredSessions = filteredSessions.filter((session) => {
return session.meta.firstCommand !== null;
return session.meta.firstUserMessage !== null;
});
}
@@ -52,9 +52,9 @@ const LayerImpl = Effect.gen(function* () {
for (const session of filteredSessions) {
// Generate title for comparison
const title =
session.meta.firstCommand !== null
session.meta.firstUserMessage !== null
? (() => {
const cmd = session.meta.firstCommand;
const cmd = session.meta.firstUserMessage;
switch (cmd.kind) {
case "command":
return cmd.commandArgs === undefined

View File

@@ -0,0 +1,22 @@
import type { ExtendedConversation } from "../../types";
export const extractFirstUserText = (
conversation: ExtendedConversation,
): string | null => {
if (conversation.type !== "user") {
return null;
}
const firstUserText =
typeof conversation.message.content === "string"
? conversation.message.content
: (() => {
const firstContent = conversation.message.content.at(0);
if (firstContent === undefined) return null;
if (typeof firstContent === "string") return firstContent;
if (firstContent.type === "text") return firstContent.text;
return null;
})();
return firstUserText;
};

View File

@@ -0,0 +1,58 @@
import {
type ParsedUserMessage,
parseUserMessage,
} from "../../claude-code/functions/parseUserMessage";
import type { ExtendedConversation } from "../../types";
import { extractFirstUserText } from "./extractFirstUserText";
const ignoreCommands = [
"/clear",
"/login",
"/logout",
"/exit",
"/mcp",
"/memory",
];
export const extractFirstUserMessage = (
conversation: ExtendedConversation,
): ParsedUserMessage | undefined => {
if (conversation.type !== "user") {
return undefined;
}
if (conversation.isSidechain === true) {
return undefined;
}
const firstUserText = extractFirstUserText(conversation);
if (firstUserText === null) {
return undefined;
}
if (
firstUserText ===
"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."
) {
return undefined;
}
if (firstUserText === "Warmup") {
return undefined;
}
const command = parseUserMessage(firstUserText);
if (command.kind === "local-command") {
return undefined;
}
if (
command.kind === "command" &&
ignoreCommands.includes(command.commandName)
) {
return undefined;
}
return command;
};

View File

@@ -116,7 +116,7 @@ describe("SessionRepository", () => {
const mockDate = new Date("2024-01-01T00:00:00.000Z");
const mockMeta: SessionMeta = {
messageCount: 3,
firstCommand: null,
firstUserMessage: null,
};
const mockContent = `{"type":"user","message":{"role":"user","content":"Hello"}}\n{"type":"assistant","message":{"role":"assistant","content":"Hi"}}\n{"type":"user","message":{"role":"user","content":"Test"}}`;
@@ -198,7 +198,7 @@ describe("SessionRepository", () => {
const PersistentServiceMock = makePersistentServiceMock();
const SessionMetaServiceMock = makeSessionMetaServiceMock({
messageCount: 0,
firstCommand: null,
firstUserMessage: null,
});
const PredictSessionsDatabaseMock = Layer.succeed(
VirtualConversationDatabase,
@@ -254,7 +254,7 @@ describe("SessionRepository", () => {
const PersistentServiceMock = makePersistentServiceMock();
const SessionMetaServiceMock = makeSessionMetaServiceMock({
messageCount: 0,
firstCommand: null,
firstUserMessage: null,
});
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
new Map(),
@@ -291,7 +291,7 @@ describe("SessionRepository", () => {
const PersistentServiceMock = makePersistentServiceMock();
const SessionMetaServiceMock = makeSessionMetaServiceMock({
messageCount: 0,
firstCommand: null,
firstUserMessage: null,
});
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
new Map(),
@@ -326,7 +326,7 @@ describe("SessionRepository", () => {
const mockMeta: SessionMeta = {
messageCount: 1,
firstCommand: null,
firstUserMessage: null,
};
const FileSystemMock = makeFileSystemMock({
@@ -381,7 +381,7 @@ describe("SessionRepository", () => {
const mockMeta: SessionMeta = {
messageCount: 1,
firstCommand: null,
firstUserMessage: null,
};
const FileSystemMock = makeFileSystemMock({
@@ -430,7 +430,7 @@ describe("SessionRepository", () => {
const mockMeta: SessionMeta = {
messageCount: 1,
firstCommand: null,
firstUserMessage: null,
};
const FileSystemMock = makeFileSystemMock({
@@ -495,7 +495,7 @@ describe("SessionRepository", () => {
const PersistentServiceMock = makePersistentServiceMock();
const SessionMetaServiceMock = makeSessionMetaServiceMock({
messageCount: 0,
firstCommand: null,
firstUserMessage: null,
});
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
new Map(),
@@ -528,7 +528,7 @@ describe("SessionRepository", () => {
const mockMeta: SessionMeta = {
messageCount: 1,
firstCommand: null,
firstUserMessage: null,
};
const mockConversations: (Conversation | ErrorJsonl)[] = [

View File

@@ -1,8 +1,8 @@
import { FileSystem, Path } from "@effect/platform";
import { Context, Effect, Layer, Option } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { parseCommandXml } from "../../claude-code/functions/parseCommandXml";
import { parseJsonl } from "../../claude-code/functions/parseJsonl";
import { parseUserMessage } from "../../claude-code/functions/parseUserMessage";
import { decodeProjectId } from "../../project/functions/id";
import type { Session, SessionDetail } from "../../types";
import { decodeSessionId, encodeSessionId } from "../functions/id";
@@ -101,7 +101,7 @@ const LayerImpl = Effect.gen(function* () {
jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`,
meta: {
messageCount: 0,
firstCommand: null,
firstUserMessage: null,
},
conversations: virtualConversation.conversations,
lastModifiedAt:
@@ -261,8 +261,8 @@ const LayerImpl = Effect.gen(function* () {
last !== undefined ? new Date(last.timestamp) : new Date(),
meta: {
messageCount: conversations.length,
firstCommand: firstUserText
? parseCommandXml(firstUserText)
firstUserMessage: firstUserText
? parseUserMessage(firstUserText)
: null,
},
};

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { parsedCommandSchema } from "../claude-code/functions/parseCommandXml";
import { parsedUserMessageSchema } from "../claude-code/functions/parseUserMessage";
export const sessionMetaSchema = z.object({
messageCount: z.number(),
firstCommand: parsedCommandSchema.nullable(),
firstUserMessage: parsedUserMessageSchema.nullable(),
});

View File

@@ -66,7 +66,7 @@ describe("SessionMetaService", () => {
);
expect(result.messageCount).toBe(1);
expect(result.firstCommand).toEqual({
expect(result.firstUserMessage).toEqual({
kind: "text",
content: "test message",
});
@@ -153,7 +153,7 @@ describe("SessionMetaService", () => {
),
);
expect(result.firstCommand).toEqual({
expect(result.firstUserMessage).toEqual({
kind: "command",
commandName: "/test",
});
@@ -192,7 +192,7 @@ describe("SessionMetaService", () => {
),
);
expect(result.firstCommand).toEqual({
expect(result.firstUserMessage).toEqual({
kind: "text",
content: "actual message",
});

View File

@@ -5,25 +5,16 @@ import {
makeFileCacheStorageLayer,
} from "../../../lib/storage/FileCacheStorage";
import { PersistentService } from "../../../lib/storage/FileCacheStorage/PersistentService";
import {
type ParsedCommand,
parseCommandXml,
parsedCommandSchema,
} from "../../claude-code/functions/parseCommandXml";
import { parseJsonl } from "../../claude-code/functions/parseJsonl";
import {
type ParsedUserMessage,
parsedUserMessageSchema,
} from "../../claude-code/functions/parseUserMessage";
import type { SessionMeta } from "../../types";
import { decodeSessionId } from "../functions/id";
import { extractFirstUserMessage } from "../functions/isValidFirstMessage";
const ignoreCommands = [
"/clear",
"/login",
"/logout",
"/exit",
"/mcp",
"/memory",
];
const parsedCommandOrNullSchema = parsedCommandSchema.nullable();
const parsedUserMessageOrNullSchema = parsedUserMessageSchema.nullable();
export class SessionMetaService extends Context.Tag("SessionMetaService")<
SessionMetaService,
@@ -42,43 +33,23 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
this,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const firstCommandCache = yield* FileCacheStorage<ParsedCommand | null>();
const firstCommandCache =
yield* FileCacheStorage<ParsedUserMessage | null>();
const sessionMetaCacheRef = yield* Ref.make(
new Map<string, SessionMeta>(),
);
const extractFirstUserText = (
conversation: Exclude<ReturnType<typeof parseJsonl>[0], undefined>,
): string | null => {
if (conversation.type !== "user") {
return null;
}
const firstUserText =
typeof conversation.message.content === "string"
? conversation.message.content
: (() => {
const firstContent = conversation.message.content.at(0);
if (firstContent === undefined) return null;
if (typeof firstContent === "string") return firstContent;
if (firstContent.type === "text") return firstContent.text;
return null;
})();
return firstUserText;
};
const getFirstCommand = (
const getFirstUserMessage = (
jsonlFilePath: string,
lines: string[],
): Effect.Effect<ParsedCommand | null, Error> =>
): Effect.Effect<ParsedUserMessage | null, Error> =>
Effect.gen(function* () {
const cached = yield* firstCommandCache.get(jsonlFilePath);
if (cached !== undefined) {
return cached;
}
let firstCommand: ParsedCommand | null = null;
let firstUserMessage: ParsedUserMessage | null = null;
for (const line of lines) {
const conversation = parseJsonl(line).at(0);
@@ -87,40 +58,22 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
continue;
}
const firstUserText = extractFirstUserText(conversation);
const maybeFirstUserMessage = extractFirstUserMessage(conversation);
if (firstUserText === null) {
if (maybeFirstUserMessage === undefined) {
continue;
}
if (
firstUserText ===
"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."
) {
continue;
}
firstUserMessage = maybeFirstUserMessage;
const command = parseCommandXml(firstUserText);
if (command.kind === "local-command") {
continue;
}
if (
command.kind === "command" &&
ignoreCommands.includes(command.commandName)
) {
continue;
}
firstCommand = command;
break;
}
if (firstCommand !== null) {
yield* firstCommandCache.set(jsonlFilePath, firstCommand);
if (firstUserMessage !== null) {
yield* firstCommandCache.set(jsonlFilePath, firstUserMessage);
}
return firstCommand;
return firstUserMessage;
});
const getSessionMeta = (
@@ -138,11 +91,14 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
const content = yield* fs.readFileString(sessionPath);
const lines = content.split("\n");
const firstCommand = yield* getFirstCommand(sessionPath, lines);
const firstUserMessage = yield* getFirstUserMessage(
sessionPath,
lines,
);
const sessionMeta: SessionMeta = {
messageCount: lines.length,
firstCommand,
firstUserMessage: firstUserMessage,
};
yield* Ref.update(sessionMetaCacheRef, (cache) => {
@@ -172,8 +128,8 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
).pipe(
Layer.provide(
makeFileCacheStorageLayer(
"first-command-cache",
parsedCommandOrNullSchema,
"first-user-message-cache",
parsedUserMessageOrNullSchema,
),
),
Layer.provide(PersistentService.Live),

View File

@@ -27,6 +27,8 @@ export type ErrorJsonl = {
lineNumber: number;
};
export type ExtendedConversation = Conversation | ErrorJsonl;
export type SessionDetail = Session & {
conversations: (Conversation | ErrorJsonl)[];
conversations: ExtendedConversation[];
};

View File

@@ -38,7 +38,7 @@ describe("InitializeService", () => {
lastModifiedAt: Date;
meta: {
messageCount: number;
firstCommand: {
firstUserMessage: {
kind: "command";
commandName: string;
commandArgs?: string;
@@ -72,7 +72,7 @@ describe("InitializeService", () => {
getSessionMeta: () =>
Effect.succeed({
messageCount: 0,
firstCommand: null,
firstUserMessage: null,
}),
invalidateSession: () => Effect.void,
});
@@ -134,7 +134,7 @@ describe("InitializeService", () => {
lastModifiedAt: new Date(),
meta: {
messageCount: 5,
firstCommand: {
firstUserMessage: {
kind: "command",
commandName: "test",
},
@@ -146,7 +146,7 @@ describe("InitializeService", () => {
lastModifiedAt: new Date(),
meta: {
messageCount: 3,
firstCommand: null,
firstUserMessage: null,
},
},
]);
@@ -318,7 +318,7 @@ describe("InitializeService", () => {
lastModifiedAt: new Date(),
meta: {
messageCount: 5,
firstCommand: {
firstUserMessage: {
kind: "command",
commandName: "test",
},