diff --git a/packages/cli/src/components/session-shell.tsx b/packages/cli/src/components/session-shell.tsx index 33a1ea3..e856a62 100644 --- a/packages/cli/src/components/session-shell.tsx +++ b/packages/cli/src/components/session-shell.tsx @@ -3,7 +3,6 @@ import type { ReactNode, RefObject } from "react"; import { InputBar } from "./input-bar"; import { Spinner } from "./spinner"; import { usePromptConfig } from "../providers/prompt-config"; -import { useTheme } from "../providers/theme"; /** Session layout: scrollable transcript, input bar, and status footer. */ type Props = { @@ -37,7 +36,6 @@ export function SessionShell({ // Footer spinner reads mode so its color matches the input border accent. const { mode } = usePromptConfig(); - const { colors } = useTheme(); return ( { + if (tempDir) { + process.chdir(originalCwd); + tempDir = null; + } +}); + +function withTempProject(run: (projectDir: string) => Promise): Promise { + tempDir = mkdtempSync(join(tmpdir(), "mocode-local-tools-")); + process.chdir(tempDir); + return run(tempDir); +} + describe("executeLocalTool PLAN mode guards", () => { test("gitStatus succeeds in PLAN mode", async () => { await expect(executeLocalTool("gitStatus", {}, Mode.PLAN)).resolves.toBeDefined(); @@ -24,4 +43,71 @@ describe("executeLocalTool readFile", () => { expect(result.content).toBe("{"); }); + + test("rejects line_start greater than line_end", async () => { + await expect( + executeLocalTool("readFile", { path: "package.json", line_start: 10, line_end: 1 }, Mode.PLAN), + ).rejects.toThrow(/line_start must be less than or equal to line_end/); + }); + + test("reads line_start through EOF when line_end is omitted", async () => { + await withTempProject(async () => { + writeFileSync("sample.txt", "alpha\nbeta\ngamma\n", "utf-8"); + + const result = (await executeLocalTool( + "readFile", + { path: "sample.txt", line_start: 2 }, + Mode.PLAN, + )) as { content: string }; + + expect(result.content).toBe("beta\ngamma"); + }); + }); + + test("streams only the requested line range from a large file", async () => { + await withTempProject(async () => { + const lines = Array.from({ length: 5_000 }, (_, index) => `line-${index + 1}`); + writeFileSync("large.txt", `${lines.join("\n")}\n`, "utf-8"); + + const result = (await executeLocalTool( + "readFile", + { path: "large.txt", line_start: 100, line_end: 102 }, + Mode.PLAN, + )) as { content: string }; + + expect(result.content).toBe("line-100\nline-101\nline-102"); + }); + }); + + test("truncates full-file reads at MAX_FILE_SIZE without loading beyond limit", async () => { + await withTempProject(async () => { + writeFileSync("huge.txt", "x".repeat(12_000), "utf-8"); + + const result = (await executeLocalTool( + "readFile", + { path: "huge.txt" }, + Mode.PLAN, + )) as { content: string; truncated?: boolean }; + + expect(result.content).toHaveLength(10_000); + expect(result.truncated).toBe(true); + }); + }); + + test("truncates line-range reads when selected range exceeds MAX_FILE_SIZE", async () => { + await withTempProject(async () => { + mkdirSync("nested", { recursive: true }); + writeFileSync(join("nested", "wide.txt"), `${"y".repeat(12_000)}\n`, "utf-8"); + + const result = (await executeLocalTool( + "readFile", + { path: "nested/wide.txt", line_start: 1, line_end: 1 }, + Mode.PLAN, + )) as { content: string; truncated?: boolean; totalLength?: number }; + + expect(result.content).toHaveLength(10_000); + expect(result.truncated).toBe(true); + expect(result.totalLength).toBe(12_000); + }); + }); }); diff --git a/packages/cli/src/lib/local-tools.ts b/packages/cli/src/lib/local-tools.ts index 2a10451..534eca0 100644 --- a/packages/cli/src/lib/local-tools.ts +++ b/packages/cli/src/lib/local-tools.ts @@ -15,6 +15,8 @@ * Security: every path is resolved and checked against `process.cwd()` so the * agent cannot read or write outside the project tree. */ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; import { mkdir, readFile, readdir, stat, writeFile } from "fs/promises"; import { dirname, isAbsolute, join, relative, resolve } from "path"; import { toolInputSchemas, Mode, type ModeType } from "@mocode/shared"; @@ -64,20 +66,69 @@ function truncate(value: string, limit: number) { : value; } -/** Returns a line slice when line_start/line_end are set (1-based, inclusive). */ -function sliceFileLines(content: string, lineStart?: number, lineEnd?: number): string { - if (lineStart === undefined && lineEnd === undefined) { - return content; + +/** Streams only the requested line range — avoids loading entire large files. */ +async function readLinesFromFile( + resolved: string, + lineStart?: number, + lineEnd?: number, +): Promise { + const startLine = lineStart ?? 1; + const lines: string[] = []; + const rl = createInterface({ + input: createReadStream(resolved, { encoding: "utf-8" }), + crlfDelay: Number.POSITIVE_INFINITY, + }); + + let lineNumber = 0; + for await (const line of rl) { + lineNumber += 1; + if (lineNumber < startLine) continue; + if (lineEnd !== undefined && lineNumber > lineEnd) break; + lines.push(line); } - const lines = content.split("\n"); - const startIndex = Math.max(1, lineStart ?? 1) - 1; - const endIndex = lineEnd === undefined ? lines.length : Math.min(lineEnd, lines.length); - if (endIndex < startIndex + 1) { - return ""; + return lines.join("\n"); +} + +/** Streams file content up to a character limit — avoids loading entire large files. */ +async function readFileUpToCharLimit( + resolved: string, + limit: number, +): Promise<{ content: string; truncated: boolean }> { + const stream = createReadStream(resolved, { encoding: "utf-8" }); + const chunks: string[] = []; + let length = 0; + let truncated = false; + + for await (const chunk of stream) { + const text = String(chunk); + if (length + text.length <= limit) { + chunks.push(text); + length += text.length; + continue; + } + + const remaining = limit - length; + if (remaining > 0) { + chunks.push(text.slice(0, remaining)); + length += remaining; + } + truncated = true; + stream.destroy(); + break; } - return lines.slice(startIndex, endIndex).join("\n"); + return { content: chunks.join(""), truncated }; +} + +function applyCharLimit( + content: string, + limit: number, +): { content: string; truncated?: boolean; totalLength?: number } { + return content.length > limit + ? { content: content.slice(0, limit), truncated: true, totalLength: content.length } + : { content }; } /** @@ -100,11 +151,21 @@ export async function executeLocalTool(toolName: string, input: unknown, mode: M case "readFile": { const { path, line_start, line_end } = toolInputSchemas.readFile.parse(input); const { resolved } = resolveInsideCwd(path); - const raw = await readFile(resolved, "utf-8"); - const content = sliceFileLines(raw, line_start, line_end); - return content.length > MAX_FILE_SIZE - ? { content: content.slice(0, MAX_FILE_SIZE), truncated: true, totalLength: content.length } - : { content }; + + if (line_start !== undefined || line_end !== undefined) { + const content = await readLinesFromFile(resolved, line_start, line_end); + const limited = applyCharLimit(content, MAX_FILE_SIZE); + return limited.truncated + ? { + content: limited.content, + truncated: true, + totalLength: limited.totalLength, + } + : { content: limited.content }; + } + + const { content, truncated } = await readFileUpToCharLimit(resolved, MAX_FILE_SIZE); + return truncated ? { content, truncated: true } : { content }; } case "listDirectory": { const { path } = toolInputSchemas.listDirectory.parse(input); diff --git a/packages/cli/src/lib/system-prompt.test.ts b/packages/cli/src/lib/system-prompt.test.ts index d3f69c9..a98d63b 100644 --- a/packages/cli/src/lib/system-prompt.test.ts +++ b/packages/cli/src/lib/system-prompt.test.ts @@ -1,22 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildSkillsSection, buildSystemPrompt } from "./system-prompt"; - -describe("buildSkillsSection (D-29)", () => { - test("returns empty string when no skills", () => { - expect(buildSkillsSection([])).toBe(""); - }); - - test("lists skill name and description", () => { - const section = buildSkillsSection([ - { name: "write-tests", description: "Generate tests for a module" }, - ]); - - expect(section).toContain("Available Skills"); - expect(section).toContain("write-tests"); - expect(section).toContain("Generate tests for a module"); - expect(section).toContain("/skill-name"); - }); -}); +import { buildSystemPrompt } from "./system-prompt"; describe("buildSystemPrompt skills integration", () => { test("includes skills section when skills provided", () => { diff --git a/packages/cli/src/lib/system-prompt.ts b/packages/cli/src/lib/system-prompt.ts index f50a831..1ead5ea 100644 --- a/packages/cli/src/lib/system-prompt.ts +++ b/packages/cli/src/lib/system-prompt.ts @@ -6,12 +6,8 @@ * - Code heuristics (`mcp/heuristics.ts`) gate execution and PLAN filtering * - Prompt rules here steer the model toward `mcp__*` when the user asks for MCP */ -import type { ModeType } from "@mocode/shared"; - -export type SkillPromptEntry = { - name: string; - description: string; -}; +import type { ModeType, SkillPromptEntry } from "@mocode/shared"; +import { buildSkillsSection } from "@mocode/shared"; type SystemPromptParams = { mode: ModeType; @@ -63,22 +59,6 @@ function buildMcpToolsSection( 5. PLAN mode exposes read-only MCP tools only (${mode === "PLAN" ? "filtered" : "get/list/read/fetch/search prefixes"})`; } -/** Lists discoverable skills for the main agent (D-29). */ -export function buildSkillsSection(skills: SkillPromptEntry[]): string { - if (skills.length === 0) { - return ""; - } - - const bullets = skills - .map((skill) => `- **${skill.name}** — ${skill.description}`) - .join("\n "); - - return ` - # Available Skills - Invoke via slash command (e.g. /skill-name): - ${bullets}`; -} - /** Thinking steps reorder when MCP is live — route external tools before repo grep/glob. */ function buildThinkingProcess(hasMcp: boolean): string { if (hasMcp) { diff --git a/packages/server/src/system-prompt.test.ts b/packages/server/src/system-prompt.test.ts index 9ab8b93..e585f41 100644 --- a/packages/server/src/system-prompt.test.ts +++ b/packages/server/src/system-prompt.test.ts @@ -83,4 +83,16 @@ describe("buildSystemPrompt", () => { expect(toolsSection!).not.toMatch(/\bbash\b/); }); }); + + describe("skills section", () => { + test("includes shared skills prompt when skills provided", () => { + const prompt = buildSystemPrompt({ + mode: "BUILD", + skills: [{ name: "write-tests", description: "Generate tests" }], + }); + + expect(prompt).toContain("Available Skills"); + expect(prompt).toContain("write-tests"); + }); + }); }); diff --git a/packages/server/src/system-prompt.ts b/packages/server/src/system-prompt.ts index 1d71814..84f9bd2 100644 --- a/packages/server/src/system-prompt.ts +++ b/packages/server/src/system-prompt.ts @@ -9,12 +9,8 @@ * Tool invocations are streamed from the server but executed in the user's local * project directory by the CLI ({@link executeLocalTool}). */ -import type { ModeType } from "@mocode/shared"; - -type SkillPromptEntry = { - name: string; - description: string; -}; +import type { ModeType, SkillPromptEntry } from "@mocode/shared"; +import { buildSkillsSection } from "@mocode/shared"; type SystemPromptParams = { mode: ModeType; @@ -42,21 +38,6 @@ const BUILD_BASH_PERMISSION_RULES = ` 10. When command intent is not obvious from the command string alone, include the optional description field on bash tool calls 11. If bash returns output-error from user rejection, do not retry the same command unless the user explicitly asks again; acknowledge the rejection and suggest alternatives — do not ask the user to confirm via chat (no typed confirmation phrases, no "reply X to continue"); the TUI approval dialog was the sole approval step and chat must never become a secondary permission gate; do not offer to retry the same rejected command contingent on chat confirmation (no "after you confirm", "if you confirm", or "once you confirm" phrasing); do not present chat replies or numbered option menus as the permission gate to retry — if the user wants the same command again, they must explicitly request it in a new message, which will invoke bash and the TUI approval dialog again`; -function buildSkillsSection(skills: SkillPromptEntry[]): string { - if (skills.length === 0) { - return ""; - } - - const bullets = skills - .map((skill) => `- **${skill.name}** — ${skill.description}`) - .join("\n "); - - return ` - # Available Skills - Invoke via slash command (e.g. /skill-name): - ${bullets}`; -} - /** Assembles mode-specific instructions, tool rules, and response format. */ export function buildSystemPrompt({ mode, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 226fd25..42c0a4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,4 +31,9 @@ export { export { hasVisibleAssistantContent, type AssistantMessageLike, -} from "./assistant-content"; \ No newline at end of file +} from "./assistant-content"; + +export { + buildSkillsSection, + type SkillPromptEntry, +} from "./skills-prompt"; \ No newline at end of file diff --git a/packages/shared/src/schemas.test.ts b/packages/shared/src/schemas.test.ts index 3b82c79..c890447 100644 --- a/packages/shared/src/schemas.test.ts +++ b/packages/shared/src/schemas.test.ts @@ -108,4 +108,20 @@ describe("readFile tool schema", () => { schema.safeParse({ path: "src/index.ts", line_start: 1, line_end: 40 }).success, ).toBe(true); }); + + test("accepts line_start without line_end", () => { + const schema = toolInputSchemas.readFile; + expect(schema.safeParse({ path: "src/index.ts", line_start: 10 }).success).toBe(true); + }); + + test("rejects line_start greater than line_end", () => { + const schema = toolInputSchemas.readFile; + const result = schema.safeParse({ path: "src/index.ts", line_start: 50, line_end: 10 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message.includes("line_start"))).toBe( + true, + ); + } + }); }); diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 45aee3f..760a4d6 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -23,21 +23,29 @@ export type ModeType = (typeof Mode)[keyof typeof Mode]; /** Zod input schemas keyed by tool name; shared between server contracts and CLI validation. */ export const toolInputSchemas = { - readFile: z.object({ - path: z.string().describe("Relative path to the file to read"), - line_start: z - .number() - .int() - .min(1) - .optional() - .describe("Optional 1-based start line (inclusive) for partial reads"), - line_end: z - .number() - .int() - .min(1) - .optional() - .describe("Optional 1-based end line (inclusive) for partial reads"), - }), + readFile: z + .object({ + path: z.string().describe("Relative path to the file to read"), + line_start: z + .number() + .int() + .min(1) + .optional() + .describe("Optional 1-based start line (inclusive) for partial reads"), + line_end: z + .number() + .int() + .min(1) + .optional() + .describe("Optional 1-based end line (inclusive) for partial reads"), + }) + .refine( + (data) => + data.line_start === undefined || + data.line_end === undefined || + data.line_start <= data.line_end, + { message: "line_start must be less than or equal to line_end", path: ["line_end"] }, + ), listDirectory: z.object({ path: z.string().default(".").describe("Relative directory path to list"), }), diff --git a/packages/shared/src/skills-prompt.test.ts b/packages/shared/src/skills-prompt.test.ts new file mode 100644 index 0000000..7c729ea --- /dev/null +++ b/packages/shared/src/skills-prompt.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { buildSkillsSection } from "./skills-prompt"; + +describe("buildSkillsSection (D-29)", () => { + test("returns empty string when no skills", () => { + expect(buildSkillsSection([])).toBe(""); + }); + + test("lists skill name and description", () => { + const section = buildSkillsSection([ + { name: "write-tests", description: "Generate tests for a module" }, + ]); + + expect(section).toContain("Available Skills"); + expect(section).toContain("write-tests"); + expect(section).toContain("Generate tests for a module"); + expect(section).toContain("/skill-name"); + }); +}); diff --git a/packages/shared/src/skills-prompt.ts b/packages/shared/src/skills-prompt.ts new file mode 100644 index 0000000..085963a --- /dev/null +++ b/packages/shared/src/skills-prompt.ts @@ -0,0 +1,20 @@ +export type SkillPromptEntry = { + name: string; + description: string; +}; + +/** Lists discoverable skills for the main agent system prompt (D-29). */ +export function buildSkillsSection(skills: SkillPromptEntry[]): string { + if (skills.length === 0) { + return ""; + } + + const bullets = skills + .map((skill) => `- **${skill.name}** — ${skill.description}`) + .join("\n "); + + return ` + # Available Skills + Invoke via slash command (e.g. /skill-name): + ${bullets}`; +}