Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/cli/src/components/session-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 (
<box
width="100%"
Expand Down
88 changes: 87 additions & 1 deletion packages/cli/src/lib/local-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { Mode } from "@mocode/shared";
import { executeLocalTool } from "./local-tools";

const originalCwd = process.cwd();
let tempDir: string | null = null;

afterEach(() => {
if (tempDir) {
process.chdir(originalCwd);
tempDir = null;
}
});

function withTempProject(run: (projectDir: string) => Promise<void>): Promise<void> {
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();
Expand All @@ -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);
});
});
});
91 changes: 76 additions & 15 deletions packages/cli/src/lib/local-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> {
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve CRLF in partial readFile output

When readFile is called with line_start/line_end on a CRLF file, readline strips the original \r\n terminators and this join("\n") returns LF-only text. The agent often copies partial-read snippets into editFile.oldString, but editFile matches raw file contents, so those replacements fail on CRLF projects even though the displayed snippet looks correct. Preserve the original line endings while streaming, or avoid normalizing the selected range.

Useful? React with 👍 / 👎.

}

/** 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 };
}

/**
Expand All @@ -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);
Expand Down
19 changes: 1 addition & 18 deletions packages/cli/src/lib/system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
24 changes: 2 additions & 22 deletions packages/cli/src/lib/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/server/src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
23 changes: 2 additions & 21 deletions packages/server/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ export {
export {
hasVisibleAssistantContent,
type AssistantMessageLike,
} from "./assistant-content";
} from "./assistant-content";

export {
buildSkillsSection,
type SkillPromptEntry,
} from "./skills-prompt";
16 changes: 16 additions & 0 deletions packages/shared/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
});
});
Loading
Loading