Skip to content
Open
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
7 changes: 5 additions & 2 deletions core/tools/implementations/createNewFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { inferResolvedUriFromRelativePath } from "../../util/ideUtils";
import { ToolImpl } from ".";
import { throwIfFileIsSecurityConcern } from "../../indexing/ignore";
import { getCleanUriPath, getUriPathBasename } from "../../util/uri";
import { getStringArg } from "../parseArgs";
import { getStringArg, stringifyFileContents } from "../parseArgs";
import { ContinueError, ContinueErrorReason } from "../../util/errors";

export const createNewFileImpl: ToolImpl = async (args, extras) => {
const filepath = getStringArg(args, "filepath");
const contents = getStringArg(args, "contents", true);
const contents =
typeof args?.contents === "object" && args.contents !== null
? stringifyFileContents(args.contents)
: getStringArg(args, "contents", true);

const resolvedFileUri = await inferResolvedUriFromRelativePath(
filepath,
Expand Down
46 changes: 46 additions & 0 deletions core/tools/implementations/createNewFile.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("../../util/ideUtils", () => ({
inferResolvedUriFromRelativePath: async (path: string) =>
`file:///workspace/${path}`,
}));

import { createNewFileImpl } from "./createNewFile";

function fakeExtras() {
const written: Record<string, string> = {};
const ide = {
fileExists: async () => false,
writeFile: async (uri: string, contents: string) => {
written[uri] = contents;
},
openFile: async () => {},
saveFile: async () => {},
};
return { written, extras: { ide } as any };
}

describe("createNewFileImpl", () => {
it("writes a JSON value sent as contents as indented JSON", async () => {
const { written, extras } = fakeExtras();
await createNewFileImpl(
{
filepath: "app/package.json",
contents: { name: "app", private: true },
},
extras,
);
expect(written["file:///workspace/app/package.json"]).toBe(
'{\n "name": "app",\n "private": true\n}',
);
});

it("writes string contents unchanged", async () => {
const { written, extras } = fakeExtras();
await createNewFileImpl(
{ filepath: "src/a.ts", contents: "export const a = 1;\n" },
extras,
);
expect(written["file:///workspace/src/a.ts"]).toBe("export const a = 1;\n");
});
});
20 changes: 20 additions & 0 deletions core/tools/parseArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ export function coerceArgsToSchema(
return coerced;
}

/**
* The contents of a file a tool is about to write, as text.
* Some models send the contents of a JSON file as a JSON value rather than a string,
* so the parsed arguments hold an object or array. Write it as indented JSON, the way
* the file would normally look, rather than on one line.
*/
export function stringifyFileContents(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
try {
return JSON.stringify(value, null, 2) ?? String(value);
} catch {
return String(value);
}
}

export function getStringArg(
args: any,
argName: string,
Expand Down
15 changes: 15 additions & 0 deletions core/tools/parseArgs.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getOptionalStringArg,
getStringArg,
safeParseToolCallArgs,
stringifyFileContents,
} from "./parseArgs";

describe("safeParseToolCallArgs", () => {
Expand Down Expand Up @@ -191,6 +192,20 @@ describe("getStringArg", () => {
});
});

describe("stringifyFileContents", () => {
it("should write a JSON value as indented JSON", () => {
expect(stringifyFileContents({ name: "app", deps: ["a"] })).toBe(
'{\n "name": "app",\n "deps": [\n "a"\n ]\n}',
);
});

it("should keep a string unchanged and turn a missing value into empty text", () => {
expect(stringifyFileContents("const x = 1;\n")).toBe("const x = 1;\n");
expect(stringifyFileContents(undefined)).toBe("");
expect(stringifyFileContents(null)).toBe("");
});
});

describe("getOptionalStringArg", () => {
it("should return undefined when argument is not provided", () => {
const args = { otherArg: "test" };
Expand Down
56 changes: 56 additions & 0 deletions gui/src/pages/gui/ToolCallDiv/CreateFile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen } from "@testing-library/react";
import { ToolCallState } from "core";
import { BuiltInToolNames } from "core/tools/builtIn";
import { describe, expect, it, vi } from "vitest";
import FunctionSpecificToolCallDiv from "./FunctionSpecificToolCallDiv";

// StyledMarkdownPreview needs Redux; show the Markdown source it is given instead
vi.mock("../../../components/StyledMarkdownPreview", () => ({
default: ({ source }: { source: string }) => (
<pre data-testid="preview">{source}</pre>
),
}));

function createFileCall(contents: unknown): ToolCallState {
const args = { filepath: "app/package.json", contents };
return {
toolCallId: "call-1",
toolCall: {
id: "call-1",
type: "function",
function: {
name: BuiltInToolNames.CreateNewFile,
arguments: JSON.stringify(args),
},
},
status: "generated",
// The arguments are parsed deeply, so the contents of a .json file arrive as an object
parsedArgs: args,
};
}

describe("CreateFile", () => {
it("shows the contents of a JSON file instead of [object Object]", () => {
render(
<FunctionSpecificToolCallDiv
toolCallState={createFileCall({ name: "app", version: "1.0.0" })}
historyIndex={0}
/>,
);
const source = screen.getByTestId("preview").textContent ?? "";
expect(source).not.toContain("[object Object]");
expect(source).toContain('{\n "name": "app",\n "version": "1.0.0"\n}');
});

it("shows string contents unchanged", () => {
render(
<FunctionSpecificToolCallDiv
toolCallState={createFileCall("export const x = 1;")}
historyIndex={0}
/>,
);
expect(screen.getByTestId("preview").textContent).toContain(
"export const x = 1;",
);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ToolCallState } from "core";
import { BuiltInToolNames } from "core/tools/builtIn";
import { EditOperation } from "core/tools/definitions/multiEdit";
import { stringifyFileContents } from "core/tools/parseArgs";
import { CreateFile } from "./CreateFile";
import { EditFile } from "./EditFile";
import { FindAndReplaceDisplay } from "./FindAndReplace";
Expand All @@ -22,7 +23,7 @@ function FunctionSpecificToolCallDiv({
return (
<CreateFile
relativeFilepath={args?.filepath ?? ""}
fileContents={args?.contents ?? ""}
fileContents={stringifyFileContents(args?.contents)}
historyIndex={historyIndex}
/>
);
Expand Down
Loading