mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 23:04:55 +00:00
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { Prompt } from "@/context/prompt"
|
|
import { buildRequestParts } from "./build-request-parts"
|
|
|
|
describe("buildRequestParts", () => {
|
|
test("builds typed request and optimistic parts without cast path", () => {
|
|
const prompt: Prompt = [
|
|
{ type: "text", content: "hello", start: 0, end: 5 },
|
|
{
|
|
type: "file",
|
|
path: "src/foo.ts",
|
|
content: "@src/foo.ts",
|
|
start: 5,
|
|
end: 16,
|
|
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
|
},
|
|
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
|
]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
|
images: [
|
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
|
],
|
|
text: "hello @src/foo.ts @planner",
|
|
messageID: "msg_1",
|
|
sessionID: "ses_1",
|
|
sessionDirectory: "/repo",
|
|
})
|
|
|
|
expect(result.requestParts[0]?.type).toBe("text")
|
|
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
|
expect(
|
|
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
|
).toBe(true)
|
|
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
|
expect(
|
|
result.requestParts.some(
|
|
(part) =>
|
|
part.type === "text" &&
|
|
part.synthetic &&
|
|
part.metadata?.opencodeComment &&
|
|
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
|
|
),
|
|
).toBe(true)
|
|
|
|
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
|
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
|
})
|
|
|
|
test("keeps multiple uploaded attachments in order", () => {
|
|
const result = buildRequestParts({
|
|
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
|
context: [],
|
|
images: [
|
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
|
{
|
|
type: "image",
|
|
id: "img_2",
|
|
filename: "b.pdf",
|
|
mime: "application/pdf",
|
|
dataUrl: "data:application/pdf;base64,BBB",
|
|
},
|
|
],
|
|
text: "check these",
|
|
messageID: "msg_multi",
|
|
sessionID: "ses_multi",
|
|
sessionDirectory: "/repo",
|
|
})
|
|
|
|
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
|
|
|
expect(files).toHaveLength(2)
|
|
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
|
})
|
|
|
|
test("deduplicates context files when prompt already includes same path", () => {
|
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [
|
|
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
|
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
|
],
|
|
images: [],
|
|
text: "@src/foo.ts",
|
|
messageID: "msg_2",
|
|
sessionID: "ses_2",
|
|
sessionDirectory: "/repo",
|
|
})
|
|
|
|
const fooFiles = result.requestParts.filter(
|
|
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
|
)
|
|
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
|
|
|
expect(fooFiles).toHaveLength(2)
|
|
expect(synthetic).toHaveLength(1)
|
|
})
|
|
|
|
test("adds file parts for @mentions inside comment text", () => {
|
|
const result = buildRequestParts({
|
|
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
|
context: [
|
|
{
|
|
key: "ctx:comment-mention",
|
|
type: "file",
|
|
path: "src/review.ts",
|
|
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
|
},
|
|
],
|
|
images: [],
|
|
text: "look",
|
|
messageID: "msg_comment_mentions",
|
|
sessionID: "ses_comment_mentions",
|
|
sessionDirectory: "/repo",
|
|
})
|
|
|
|
const files = result.requestParts.filter((part) => part.type === "file")
|
|
expect(files).toHaveLength(2)
|
|
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
|
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
|
})
|
|
|
|
test("handles Windows paths correctly (simulated on macOS)", () => {
|
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@src\\foo.ts",
|
|
messageID: "msg_win_1",
|
|
sessionID: "ses_win_1",
|
|
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
|
})
|
|
|
|
// Should create valid file URLs
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// URL should be parseable
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
// Should not have encoded backslashes in wrong place
|
|
expect(filePart.url).not.toContain("%5C")
|
|
// Should have normalized to forward slashes
|
|
expect(filePart.url).toContain("/src/foo.ts")
|
|
}
|
|
})
|
|
|
|
test("handles Windows absolute path with special characters", () => {
|
|
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@file#name.txt",
|
|
messageID: "msg_win_2",
|
|
sessionID: "ses_win_2",
|
|
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
|
})
|
|
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// URL should be parseable
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
// Special chars should be encoded
|
|
expect(filePart.url).toContain("file%23name.txt")
|
|
// Should have Windows drive letter properly encoded
|
|
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
|
}
|
|
})
|
|
|
|
test("handles Linux absolute paths correctly", () => {
|
|
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@src/app.ts",
|
|
messageID: "msg_linux_1",
|
|
sessionID: "ses_linux_1",
|
|
sessionDirectory: "/home/user/project",
|
|
})
|
|
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// URL should be parseable
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
// Should be a normal Unix path
|
|
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
|
|
}
|
|
})
|
|
|
|
test("handles macOS paths correctly", () => {
|
|
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@README.md",
|
|
messageID: "msg_mac_1",
|
|
sessionID: "ses_mac_1",
|
|
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
|
})
|
|
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// URL should be parseable
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
// Should be a normal Unix path
|
|
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
|
}
|
|
})
|
|
|
|
test("handles context files with Windows paths", () => {
|
|
const prompt: Prompt = []
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [
|
|
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
|
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
|
],
|
|
images: [],
|
|
text: "test",
|
|
messageID: "msg_win_ctx",
|
|
sessionID: "ses_win_ctx",
|
|
sessionDirectory: "D:\\workspace\\app",
|
|
})
|
|
|
|
const fileParts = result.requestParts.filter((part) => part.type === "file")
|
|
expect(fileParts).toHaveLength(2)
|
|
|
|
// All file URLs should be valid
|
|
fileParts.forEach((part) => {
|
|
if (part.type === "file") {
|
|
expect(() => new URL(part.url)).not.toThrow()
|
|
expect(part.url).not.toContain("%5C") // No encoded backslashes
|
|
}
|
|
})
|
|
})
|
|
|
|
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
|
const prompt: Prompt = [
|
|
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
|
]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@D:\\other\\project\\file.ts",
|
|
messageID: "msg_abs",
|
|
sessionID: "ses_abs",
|
|
sessionDirectory: "C:\\current\\project",
|
|
})
|
|
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// Should handle absolute path that differs from sessionDirectory
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
|
}
|
|
})
|
|
|
|
test("handles selection with query parameters on Windows", () => {
|
|
const prompt: Prompt = [
|
|
{
|
|
type: "file",
|
|
path: "src\\App.tsx",
|
|
content: "@src\\App.tsx",
|
|
start: 0,
|
|
end: 11,
|
|
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
|
},
|
|
]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@src\\App.tsx",
|
|
messageID: "msg_sel",
|
|
sessionID: "ses_sel",
|
|
sessionDirectory: "C:\\project",
|
|
})
|
|
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// Should have query parameters
|
|
expect(filePart.url).toContain("?start=10&end=20")
|
|
// Should be valid URL
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
// Query params should parse correctly
|
|
const url = new URL(filePart.url)
|
|
expect(url.searchParams.get("start")).toBe("10")
|
|
expect(url.searchParams.get("end")).toBe("20")
|
|
}
|
|
})
|
|
|
|
test("handles file paths with dots and special segments on Windows", () => {
|
|
const prompt: Prompt = [
|
|
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
|
]
|
|
|
|
const result = buildRequestParts({
|
|
prompt,
|
|
context: [],
|
|
images: [],
|
|
text: "@..\\..\\shared\\util.ts",
|
|
messageID: "msg_dots",
|
|
sessionID: "ses_dots",
|
|
sessionDirectory: "C:\\projects\\myapp\\src",
|
|
})
|
|
|
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
expect(filePart).toBeDefined()
|
|
if (filePart?.type === "file") {
|
|
// Should be valid URL
|
|
expect(() => new URL(filePart.url)).not.toThrow()
|
|
// Should preserve .. segments (backend normalizes)
|
|
expect(filePart.url).toContain("/..")
|
|
}
|
|
})
|
|
})
|