From 36f206b332e574209cd7c41945056a522fef609d Mon Sep 17 00:00:00 2001 From: yuefengw <921592559@qq.com> Date: Thu, 14 May 2026 16:42:35 +0800 Subject: [PATCH 1/2] feat: support prompt file references --- src/prompt-file-references.ts | 232 ++++++++++++++++++++++++++++++ src/session.ts | 29 +++- src/tests/promptInputKeys.test.ts | 57 ++++++++ src/tests/session.test.ts | 83 +++++++++++ src/ui/App.tsx | 4 + src/ui/PromptInput.tsx | 15 ++ src/ui/index.ts | 8 ++ 7 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 src/prompt-file-references.ts diff --git a/src/prompt-file-references.ts b/src/prompt-file-references.ts new file mode 100644 index 0000000..0083182 --- /dev/null +++ b/src/prompt-file-references.ts @@ -0,0 +1,232 @@ +import * as fs from "fs"; +import * as path from "path"; +import { normalizeContent } from "./common/file-utils"; + +export const MAX_PROMPT_FILE_REFERENCE_BYTES = 128 * 1024; +export const MAX_PROMPT_FILE_REFERENCES_TOTAL_BYTES = 256 * 1024; + +export type PromptFileReference = { + raw: string; + path: string; + displayPath: string; + content: string; + sizeBytes: number; +}; + +export type PromptFileReferenceToken = { + raw: string; + path: string; + start: number; + end: number; +}; + +export type PromptFileReferenceError = { + raw: string; + message: string; +}; + +export type ResolvePromptFileReferencesResult = { + references: PromptFileReference[]; + errors: PromptFileReferenceError[]; +}; + +const PATH_BOUNDARY_CHARS = new Set(["(", "[", "{", "<", '"', "'", "`", ",", ":"]); + +export function parsePromptFileReferenceTokens(text: string): PromptFileReferenceToken[] { + const tokens: PromptFileReferenceToken[] = []; + + for (let index = 0; index < text.length; index += 1) { + if (text[index] !== "@") { + continue; + } + if (!isReferenceBoundary(text[index - 1])) { + continue; + } + + const next = text[index + 1]; + if (!next || /\s/.test(next)) { + continue; + } + + const quoted = next === '"' || next === "'"; + if (quoted) { + const quote = next; + let end = index + 2; + while (end < text.length && text[end] !== quote) { + end += 1; + } + if (end >= text.length) { + continue; + } + + const rawPath = text.slice(index + 2, end); + if (rawPath.trim()) { + tokens.push({ + raw: text.slice(index, end + 1), + path: rawPath, + start: index, + end: end + 1, + }); + } + index = end; + continue; + } + + let end = index + 1; + while (end < text.length && !/\s/.test(text[end])) { + end += 1; + } + + const rawPath = trimTrailingPunctuation(text.slice(index + 1, end)); + if (!rawPath) { + continue; + } + const tokenEnd = index + 1 + rawPath.length; + tokens.push({ + raw: text.slice(index, tokenEnd), + path: rawPath, + start: index, + end: tokenEnd, + }); + index = tokenEnd - 1; + } + + return tokens; +} + +export function resolvePromptFileReferences( + text: string, + projectRoot: string, + options?: { + maxFileBytes?: number; + maxTotalBytes?: number; + } +): ResolvePromptFileReferencesResult { + const maxFileBytes = options?.maxFileBytes ?? MAX_PROMPT_FILE_REFERENCE_BYTES; + const maxTotalBytes = options?.maxTotalBytes ?? MAX_PROMPT_FILE_REFERENCES_TOTAL_BYTES; + const references: PromptFileReference[] = []; + const errors: PromptFileReferenceError[] = []; + const seenPaths = new Set(); + let totalBytes = 0; + + for (const token of parsePromptFileReferenceTokens(text)) { + const absolutePath = resolveReferencePath(token.path, projectRoot); + const displayPath = formatDisplayPath(absolutePath, projectRoot); + + if (seenPaths.has(absolutePath)) { + continue; + } + seenPaths.add(absolutePath); + + let stat: fs.Stats; + try { + stat = fs.statSync(absolutePath); + } catch { + errors.push({ raw: token.raw, message: `File reference not found: ${token.raw}` }); + continue; + } + + if (!stat.isFile()) { + errors.push({ raw: token.raw, message: `File reference is not a file: ${token.raw}` }); + continue; + } + if (stat.size > maxFileBytes) { + errors.push({ + raw: token.raw, + message: `File reference is too large: ${token.raw} (${formatBytes(stat.size)}, limit ${formatBytes( + maxFileBytes + )})`, + }); + continue; + } + if (totalBytes + stat.size > maxTotalBytes) { + errors.push({ + raw: token.raw, + message: `File references are too large in total (limit ${formatBytes(maxTotalBytes)})`, + }); + continue; + } + + const buffer = fs.readFileSync(absolutePath); + if (isLikelyBinary(buffer)) { + errors.push({ raw: token.raw, message: `Binary file references are not supported: ${token.raw}` }); + continue; + } + + const content = normalizeContent(buffer.toString("utf8")); + references.push({ + raw: token.raw, + path: absolutePath, + displayPath, + content, + sizeBytes: stat.size, + }); + totalBytes += stat.size; + } + + return { references, errors }; +} + +export function buildPromptTextWithFileReferences( + text: string | undefined, + references?: PromptFileReference[] +): string { + const baseText = text ?? ""; + if (!references || references.length === 0) { + return baseText; + } + + const fileBlocks = references + .map((reference) => { + return `\n${escapeCdata(reference.content)}\n`; + }) + .join("\n\n"); + + return `${baseText}\n\n\n${fileBlocks}\n`; +} + +function resolveReferencePath(referencePath: string, projectRoot: string): string { + const expandedPath = referencePath.startsWith("~/") + ? path.join(process.env.HOME ?? process.env.USERPROFILE ?? "", referencePath.slice(2)) + : referencePath; + return path.resolve(projectRoot, expandedPath); +} + +function formatDisplayPath(absolutePath: string, projectRoot: string): string { + const relative = path.relative(projectRoot, absolutePath); + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { + return normalizeSeparators(relative); + } + return normalizeSeparators(absolutePath); +} + +function normalizeSeparators(value: string): string { + return value.replace(/\\/g, "/"); +} + +function isReferenceBoundary(previous: string | undefined): boolean { + return previous === undefined || /\s/.test(previous) || PATH_BOUNDARY_CHARS.has(previous); +} + +function trimTrailingPunctuation(value: string): string { + return value.replace(/[),.;:!?]+$/u, ""); +} + +function isLikelyBinary(buffer: Buffer): boolean { + return buffer.includes(0); +} + +function escapeXmlAttribute(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + +function escapeCdata(value: string): string { + return `/g, "]]]]>")}\n]]>`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + return `${Math.ceil(bytes / 1024)} KiB`; +} diff --git a/src/session.ts b/src/session.ts index 894ff80..aa35162 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,6 +15,7 @@ import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; +import { buildPromptTextWithFileReferences, type PromptFileReference } from "./prompt-file-references"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; @@ -143,6 +144,7 @@ export type SessionMessage = { export type UserPromptContent = { text?: string; imageUrls?: string[]; + fileReferences?: PromptFileReference[]; skills?: SkillInfo[]; }; @@ -1481,7 +1483,8 @@ ${skillMd} role: "user", content: prompt.text ?? "", contentParams: imageParams.length > 0 ? imageParams : null, - messageParams: null, + messageParams: + prompt.fileReferences && prompt.fileReferences.length > 0 ? { file_references: prompt.fileReferences } : null, compacted: false, visible: true, createTime: now, @@ -1778,9 +1781,33 @@ ${skillMd} if (message.role === "user" && message.content === "/init") { return this.renderInitCommandPrompt(); } + if (message.role === "user") { + return buildPromptTextWithFileReferences(message.content ?? "", this.getPromptFileReferences(message)); + } return message.content ?? ""; } + private getPromptFileReferences(message: SessionMessage): PromptFileReference[] | undefined { + const params = message.messageParams as { file_references?: unknown } | null | undefined; + if (!Array.isArray(params?.file_references)) { + return undefined; + } + const references = params.file_references.filter((reference): reference is PromptFileReference => { + if (!reference || typeof reference !== "object") { + return false; + } + const item = reference as Partial; + return ( + typeof item.raw === "string" && + typeof item.path === "string" && + typeof item.displayPath === "string" && + typeof item.content === "string" && + typeof item.sizeBytes === "number" + ); + }); + return references.length > 0 ? references : undefined; + } + private pairToolMessages(messages: SessionMessage[]): Map { const pairings = new Map(); const usedToolMessageIndexes = new Set(); diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 69d2075..b4be47a 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -1,5 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; const ANSI_RE = /\u001b\[[0-9;]*m/g; function stripAnsi(text: string): string { @@ -15,6 +18,8 @@ import { getPromptReturnKeyAction, isClearImageAttachmentsShortcut, parseTerminalInput, + parsePromptFileReferenceTokens, + resolvePromptFileReferences, removeCurrentSlashToken, toggleSkillSelection, renderBufferWithCursor, @@ -184,6 +189,58 @@ test("buildInitPromptSubmission preserves manually selected skills", () => { assert.deepEqual(buildInitPromptSubmission([]), { text: "/init", imageUrls: [], selectedSkills: undefined }); }); +test("parsePromptFileReferenceTokens supports unquoted and quoted @file paths", () => { + assert.deepEqual(parsePromptFileReferenceTokens('review @src/index.ts and @"docs/file with spaces.md"'), [ + { raw: "@src/index.ts", path: "src/index.ts", start: 7, end: 20 }, + { raw: '@"docs/file with spaces.md"', path: "docs/file with spaces.md", start: 25, end: 52 }, + ]); +}); + +test("parsePromptFileReferenceTokens ignores email-like mentions and trims punctuation", () => { + assert.deepEqual(parsePromptFileReferenceTokens("mail a@b.com, then inspect @src/app.ts."), [ + { raw: "@src/app.ts", path: "src/app.ts", start: 27, end: 38 }, + ]); +}); + +test("resolvePromptFileReferences reads text files relative to the project root", () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-ref-")); + try { + fs.mkdirSync(path.join(workspace, "src"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "src", "app.ts"), "export const value = 1;\r\n", "utf8"); + + const result = resolvePromptFileReferences("review @src/app.ts", workspace); + + assert.deepEqual(result.errors, []); + assert.equal(result.references.length, 1); + assert.equal(result.references[0]?.displayPath, "src/app.ts"); + assert.equal(result.references[0]?.content, "export const value = 1;\n"); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("resolvePromptFileReferences reports missing, directory, binary, and oversized files", () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-ref-errors-")); + try { + fs.mkdirSync(path.join(workspace, "src"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "binary.dat"), Buffer.from([0, 1, 2])); + fs.writeFileSync(path.join(workspace, "large.txt"), "abcd", "utf8"); + + const result = resolvePromptFileReferences("check @missing.ts @src @binary.dat @large.txt", workspace, { + maxFileBytes: 3, + maxTotalBytes: 256, + }); + + assert.deepEqual( + result.errors.map((error) => error.raw), + ["@missing.ts", "@src", "@binary.dat", "@large.txt"] + ); + assert.equal(result.references.length, 0); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + test("selected skill helpers format, dedupe, toggle, and clear slash tokens", () => { const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; const other: SkillInfo = { name: "code-review", path: "/skills/code-review/SKILL.md", description: "Review code" }; diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 2ac6c29..740937f 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { SessionManager, type SessionMessage } from "../session"; +import type { PromptFileReference } from "../prompt-file-references"; const originalFetch = globalThis.fetch; const originalHome = process.env.HOME; @@ -116,6 +117,88 @@ test("SessionManager filters image content for non-multimodal models", () => { assert.deepEqual(openAIMessages[0]?.content, [{ type: "text", text: "The read tool has loaded `pixel.png`." }]); }); +test("SessionManager appends resolved @file references to user message content", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + const fileReferences: PromptFileReference[] = [ + { + raw: "@src/app.ts", + path: path.join(process.cwd(), "src", "app.ts"), + displayPath: "src/app.ts", + content: "export const value = 1;\n", + sizeBytes: 24, + }, + ]; + + const message = (manager as any).buildUserMessage("session-1", { + text: "Please review @src/app.ts", + fileReferences, + }) as SessionMessage; + + assert.equal(message.role, "user"); + assert.equal(message.content, "Please review @src/app.ts"); + assert.deepEqual(message.messageParams, { file_references: fileReferences }); + + const openAIMessages = (manager as any).buildOpenAIMessages([message], false, "test-model") as Array<{ + content: unknown; + }>; + assert.match(String(openAIMessages[0]?.content), /Please review @src\/app\.ts/); + assert.match(String(openAIMessages[0]?.content), //); + assert.match(String(openAIMessages[0]?.content), //); + assert.match(String(openAIMessages[0]?.content), /export const value = 1;/); +}); + +test("SessionManager keeps referenced files inside text part when user prompt has images", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const message = (manager as any).buildUserMessage("session-1", { + text: "Look at this @src/app.ts", + imageUrls: ["data:image/png;base64,abc123"], + fileReferences: [ + { + raw: "@src/app.ts", + path: path.join(process.cwd(), "src", "app.ts"), + displayPath: "src/app.ts", + content: "const x = 1;\n", + sizeBytes: 13, + }, + ], + }) as SessionMessage; + const openAIMessages = (manager as any).buildOpenAIMessages([message], false, "test-model") as Array<{ + content: unknown; + }>; + + assert.deepEqual(openAIMessages[0]?.content, [ + { + type: "text", + text: 'Look at this @src/app.ts\n\n\n\n\n\n', + }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ]); +}); + test("SessionManager preserves empty reasoning content on assistant tool calls", () => { const manager = new SessionManager({ projectRoot: process.cwd(), diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3f32f56..f6130db 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -225,14 +225,17 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const prompt: UserPromptContent = { text: submission.text, imageUrls: submission.imageUrls, + fileReferences: submission.fileReferences, skills: submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, }; const trimmedText = (submission.text ?? "").trim(); const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; + const fileReferenceNames = submission.fileReferences?.map((file) => file.displayPath).filter(Boolean) ?? []; const userDisplayContent = trimmedText || + (fileReferenceNames.length > 0 ? `Referenced files: ${fileReferenceNames.join(", ")}` : "") || (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || (submission.imageUrls.length > 0 ? "[Image]" : ""); @@ -483,6 +486,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 0) { + const [firstError] = resolvedFileReferences.errors; + const suffix = + resolvedFileReferences.errors.length > 1 ? ` (+${resolvedFileReferences.errors.length - 1} more)` : ""; + setStatusMessage(`${firstError?.message ?? "Invalid file reference"}${suffix}`); + return; + } + onSubmit({ text: buffer.text, imageUrls, + fileReferences: resolvedFileReferences.references.length > 0 ? resolvedFileReferences.references : undefined, selectedSkills, }); setBuffer(EMPTY_BUFFER); diff --git a/src/ui/index.ts b/src/ui/index.ts index 5b4ff8f..9d11fbe 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -81,3 +81,11 @@ export { } from "./slashCommands"; export { findExpandedThinkingId } from "./thinkingState"; export { buildExitSummaryText } from "./exitSummary"; +export { + buildPromptTextWithFileReferences, + parsePromptFileReferenceTokens, + resolvePromptFileReferences, + type PromptFileReference, + type PromptFileReferenceError, + type PromptFileReferenceToken, +} from "../prompt-file-references"; From 20c5140d4e7f387ec58536f4968171631ad5e67e Mon Sep 17 00:00:00 2001 From: yuefengw <921592559@qq.com> Date: Sun, 17 May 2026 17:03:43 +0800 Subject: [PATCH 2/2] fix: load file references through read tool --- src/prompt-file-references.ts | 31 +----- src/prompt.ts | 1 + src/session.ts | 173 ++++++++++++++++++++++++------ src/tests/promptInputKeys.test.ts | 5 +- src/tests/session.test.ts | 55 ++++++++-- src/ui/index.ts | 1 - 6 files changed, 189 insertions(+), 77 deletions(-) diff --git a/src/prompt-file-references.ts b/src/prompt-file-references.ts index 0083182..bd3fef2 100644 --- a/src/prompt-file-references.ts +++ b/src/prompt-file-references.ts @@ -1,6 +1,5 @@ import * as fs from "fs"; import * as path from "path"; -import { normalizeContent } from "./common/file-utils"; export const MAX_PROMPT_FILE_REFERENCE_BYTES = 128 * 1024; export const MAX_PROMPT_FILE_REFERENCES_TOTAL_BYTES = 256 * 1024; @@ -9,8 +8,8 @@ export type PromptFileReference = { raw: string; path: string; displayPath: string; - content: string; sizeBytes: number; + content?: string; }; export type PromptFileReferenceToken = { @@ -153,12 +152,10 @@ export function resolvePromptFileReferences( continue; } - const content = normalizeContent(buffer.toString("utf8")); references.push({ raw: token.raw, path: absolutePath, displayPath, - content, sizeBytes: stat.size, }); totalBytes += stat.size; @@ -167,24 +164,6 @@ export function resolvePromptFileReferences( return { references, errors }; } -export function buildPromptTextWithFileReferences( - text: string | undefined, - references?: PromptFileReference[] -): string { - const baseText = text ?? ""; - if (!references || references.length === 0) { - return baseText; - } - - const fileBlocks = references - .map((reference) => { - return `\n${escapeCdata(reference.content)}\n`; - }) - .join("\n\n"); - - return `${baseText}\n\n\n${fileBlocks}\n`; -} - function resolveReferencePath(referencePath: string, projectRoot: string): string { const expandedPath = referencePath.startsWith("~/") ? path.join(process.env.HOME ?? process.env.USERPROFILE ?? "", referencePath.slice(2)) @@ -216,14 +195,6 @@ function isLikelyBinary(buffer: Buffer): boolean { return buffer.includes(0); } -function escapeXmlAttribute(value: string): string { - return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); -} - -function escapeCdata(value: string): string { - return `/g, "]]]]>")}\n]]>`; -} - function formatBytes(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; diff --git a/src/prompt.ts b/src/prompt.ts index b854860..26117ce 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -299,6 +299,7 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { role: message.role, content: message.content, contentParams: message.contentParams, + fileReferences: message.fileReferences, messageParams: message.messageParams, createTime: message.createTime, }) diff --git a/src/session.ts b/src/session.ts index aa35162..40d6c93 100644 --- a/src/session.ts +++ b/src/session.ts @@ -10,12 +10,12 @@ import { launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; -import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +import { ToolExecutor, type CreateOpenAIClient, type ToolCall } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; -import { buildPromptTextWithFileReferences, type PromptFileReference } from "./prompt-file-references"; +import type { PromptFileReference } from "./prompt-file-references"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; @@ -133,6 +133,7 @@ export type SessionMessage = { content: string | null; contentParams: unknown | null; messageParams: unknown | null; + fileReferences?: PromptFileReference[]; compacted: boolean; visible: boolean; createTime: string; @@ -875,6 +876,8 @@ The candidate skills are as follows:\n\n`; const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); + await this.appendFileReferenceReadMessages(sessionId, userMessage.fileReferences, signal); + this.throwIfAborted(signal); if (userPrompt.skills && userPrompt.skills.length > 0) { for (const skill of userPrompt.skills) { @@ -932,6 +935,8 @@ ${skillMd} const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); + await this.appendFileReferenceReadMessages(sessionId, userMessage.fileReferences, signal); + this.throwIfAborted(signal); if (userPrompt.skills && userPrompt.skills.length > 0) { for (const skill of userPrompt.skills) { @@ -1341,28 +1346,83 @@ ${skillMd} } private normalizeSessionMessage(message: SessionMessage): SessionMessage { - if (message.role !== "tool") { - return message; + const normalizedMessage = this.normalizeSessionMessageFileReferences(message); + if (normalizedMessage.role !== "tool") { + return normalizedMessage; } - const nextMeta = message.meta ? { ...message.meta } : undefined; + const nextMeta = normalizedMessage.meta ? { ...normalizedMessage.meta } : undefined; const normalizedParamsMd = this.buildToolParamsSnippet(nextMeta?.function ?? null); if (nextMeta && normalizedParamsMd) { nextMeta.paramsMd = normalizedParamsMd; } - const normalizedResultMd = typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; + const normalizedResultMd = + typeof normalizedMessage.content === "string" ? this.buildToolResultSnippet(normalizedMessage.content) : ""; if (nextMeta && normalizedResultMd) { nextMeta.resultMd = normalizedResultMd; } return { - ...message, - visible: typeof message.content === "string" ? !this.isInvisibleExecution(message.content) : message.visible, + ...normalizedMessage, + visible: + typeof normalizedMessage.content === "string" + ? !this.isInvisibleExecution(normalizedMessage.content) + : normalizedMessage.visible, meta: nextMeta, }; } + private normalizeSessionMessageFileReferences(message: SessionMessage): SessionMessage { + const directReferences = this.normalizePromptFileReferences(message.fileReferences); + const messageParams = message.messageParams; + const legacyParams = + messageParams && typeof messageParams === "object" && !Array.isArray(messageParams) + ? (messageParams as Record & { file_references?: unknown }) + : null; + const legacyReferences = this.normalizePromptFileReferences(legacyParams?.file_references); + const fileReferences = directReferences ?? legacyReferences; + + if (!fileReferences && !legacyReferences) { + return message; + } + + let nextMessageParams = messageParams; + if (legacyParams && Object.hasOwn(legacyParams, "file_references")) { + const rest = { ...legacyParams }; + delete rest.file_references; + nextMessageParams = Object.keys(rest).length > 0 ? rest : null; + } + + return { + ...message, + messageParams: nextMessageParams, + fileReferences, + }; + } + + private normalizePromptFileReferences(value: unknown): PromptFileReference[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const references = value.filter((reference): reference is PromptFileReference => { + if (!reference || typeof reference !== "object") { + return false; + } + const item = reference as Partial; + return ( + typeof item.raw === "string" && + typeof item.path === "string" && + typeof item.displayPath === "string" && + typeof item.sizeBytes === "number" && + (typeof item.content === "undefined" || typeof item.content === "string") + ); + }); + + return references.length > 0 ? references : undefined; + } + private getProjectCode(projectRoot: string): string { return projectRoot.replace(/[\\/]/g, "-").replace(/:/g, ""); } @@ -1483,8 +1543,8 @@ ${skillMd} role: "user", content: prompt.text ?? "", contentParams: imageParams.length > 0 ? imageParams : null, - messageParams: - prompt.fileReferences && prompt.fileReferences.length > 0 ? { file_references: prompt.fileReferences } : null, + messageParams: null, + fileReferences: prompt.fileReferences && prompt.fileReferences.length > 0 ? prompt.fileReferences : undefined, compacted: false, visible: true, createTime: now, @@ -1650,6 +1710,75 @@ ${skillMd} }; } + private async appendFileReferenceReadMessages( + sessionId: string, + fileReferences: PromptFileReference[] | undefined, + signal?: AbortSignal | null + ): Promise { + if (!fileReferences || fileReferences.length === 0) { + return; + } + this.throwIfAborted(signal); + + const toolCalls: ToolCall[] = fileReferences.map((reference, index) => + this.buildFileReferenceReadToolCall(reference, index) + ); + const referencesByToolCallId = new Map(toolCalls.map((toolCall, index) => [toolCall.id, fileReferences[index]])); + const executions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { + shouldStop: () => Boolean(signal?.aborted), + }); + + for (const execution of executions) { + this.throwIfAborted(signal); + const reference = referencesByToolCallId.get(execution.toolCallId); + if (!reference) { + continue; + } + + const readMessage = this.buildSystemMessage( + sessionId, + this.buildFileReferenceReadSystemPrompt(reference, execution.content), + null, + false + ); + this.appendSessionMessage(sessionId, readMessage); + + for (const followUpMessage of execution.result.followUpMessages ?? []) { + if (followUpMessage.role !== "system") { + continue; + } + this.appendSessionMessage( + sessionId, + this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null, false) + ); + } + } + } + + private buildFileReferenceReadToolCall(reference: PromptFileReference, index: number): ToolCall { + return { + id: `file-reference-read-${index}-${crypto.randomUUID()}`, + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ file_path: reference.path }), + }, + }; + } + + private buildFileReferenceReadSystemPrompt(reference: PromptFileReference, readResult: string): string { + const displayPath = reference.displayPath || reference.path; + return [ + `The user referenced \`${displayPath}\` with \`${reference.raw}\`.`, + "DeepCode loaded the referenced file by running the Read tool. The Read tool result is below.", + "If you edit this file, pass metadata.snippet.id from this result as `snippet_id` to the Edit tool when applicable.", + "", + "", + readResult, + "", + ].join("\n"); + } + private async appendToolMessages(sessionId: string, toolCalls: unknown[]): Promise<{ waitingForUser: boolean }> { const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), @@ -1781,33 +1910,9 @@ ${skillMd} if (message.role === "user" && message.content === "/init") { return this.renderInitCommandPrompt(); } - if (message.role === "user") { - return buildPromptTextWithFileReferences(message.content ?? "", this.getPromptFileReferences(message)); - } return message.content ?? ""; } - private getPromptFileReferences(message: SessionMessage): PromptFileReference[] | undefined { - const params = message.messageParams as { file_references?: unknown } | null | undefined; - if (!Array.isArray(params?.file_references)) { - return undefined; - } - const references = params.file_references.filter((reference): reference is PromptFileReference => { - if (!reference || typeof reference !== "object") { - return false; - } - const item = reference as Partial; - return ( - typeof item.raw === "string" && - typeof item.path === "string" && - typeof item.displayPath === "string" && - typeof item.content === "string" && - typeof item.sizeBytes === "number" - ); - }); - return references.length > 0 ? references : undefined; - } - private pairToolMessages(messages: SessionMessage[]): Map { const pairings = new Map(); const usedToolMessageIndexes = new Set(); diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index b4be47a..68144a0 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -202,7 +202,7 @@ test("parsePromptFileReferenceTokens ignores email-like mentions and trims punct ]); }); -test("resolvePromptFileReferences reads text files relative to the project root", () => { +test("resolvePromptFileReferences resolves text files relative to the project root", () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-ref-")); try { fs.mkdirSync(path.join(workspace, "src"), { recursive: true }); @@ -213,7 +213,8 @@ test("resolvePromptFileReferences reads text files relative to the project root" assert.deepEqual(result.errors, []); assert.equal(result.references.length, 1); assert.equal(result.references[0]?.displayPath, "src/app.ts"); - assert.equal(result.references[0]?.content, "export const value = 1;\n"); + assert.equal(result.references[0]?.path, path.join(workspace, "src", "app.ts")); + assert.equal(result.references[0]?.content, undefined); } finally { fs.rmSync(workspace, { recursive: true, force: true }); } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 740937f..3d54685 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -5,6 +5,7 @@ import * as os from "os"; import * as path from "path"; import { SessionManager, type SessionMessage } from "../session"; import type { PromptFileReference } from "../prompt-file-references"; +import { getSnippet } from "../common/state"; const originalFetch = globalThis.fetch; const originalHome = process.env.HOME; @@ -117,7 +118,7 @@ test("SessionManager filters image content for non-multimodal models", () => { assert.deepEqual(openAIMessages[0]?.content, [{ type: "text", text: "The read tool has loaded `pixel.png`." }]); }); -test("SessionManager appends resolved @file references to user message content", () => { +test("SessionManager stores resolved @file references on the user message", () => { const manager = new SessionManager({ projectRoot: process.cwd(), createOpenAIClient: () => ({ @@ -134,7 +135,6 @@ test("SessionManager appends resolved @file references to user message content", raw: "@src/app.ts", path: path.join(process.cwd(), "src", "app.ts"), displayPath: "src/app.ts", - content: "export const value = 1;\n", sizeBytes: 24, }, ]; @@ -146,18 +146,16 @@ test("SessionManager appends resolved @file references to user message content", assert.equal(message.role, "user"); assert.equal(message.content, "Please review @src/app.ts"); - assert.deepEqual(message.messageParams, { file_references: fileReferences }); + assert.equal(message.messageParams, null); + assert.deepEqual(message.fileReferences, fileReferences); const openAIMessages = (manager as any).buildOpenAIMessages([message], false, "test-model") as Array<{ content: unknown; }>; - assert.match(String(openAIMessages[0]?.content), /Please review @src\/app\.ts/); - assert.match(String(openAIMessages[0]?.content), //); - assert.match(String(openAIMessages[0]?.content), //); - assert.match(String(openAIMessages[0]?.content), /export const value = 1;/); + assert.equal(openAIMessages[0]?.content, "Please review @src/app.ts"); }); -test("SessionManager keeps referenced files inside text part when user prompt has images", () => { +test("SessionManager keeps file references separate from multimodal content", () => { const manager = new SessionManager({ projectRoot: process.cwd(), createOpenAIClient: () => ({ @@ -178,7 +176,6 @@ test("SessionManager keeps referenced files inside text part when user prompt ha raw: "@src/app.ts", path: path.join(process.cwd(), "src", "app.ts"), displayPath: "src/app.ts", - content: "const x = 1;\n", sizeBytes: 13, }, ], @@ -190,7 +187,7 @@ test("SessionManager keeps referenced files inside text part when user prompt ha assert.deepEqual(openAIMessages[0]?.content, [ { type: "text", - text: 'Look at this @src/app.ts\n\n\n\n\n\n', + text: "Look at this @src/app.ts", }, { type: "image_url", @@ -199,6 +196,44 @@ test("SessionManager keeps referenced files inside text part when user prompt ha ]); }); +test("createSession loads file references through Read tool hidden system messages", async () => { + const workspace = createTempDir("deepcode-file-ref-session-"); + const home = createTempDir("deepcode-file-ref-home-"); + process.env.HOME = home; + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(workspace, "src"), { recursive: true }); + const filePath = path.join(workspace, "src", "app.ts"); + fs.writeFileSync(filePath, "export const value = 1;\n", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-file-ref-read"); + (manager as any).activateSession = async () => {}; + + const fileReferences: PromptFileReference[] = [ + { + raw: "@src/app.ts", + path: filePath, + displayPath: "src/app.ts", + sizeBytes: fs.statSync(filePath).size, + }, + ]; + + const sessionId = await manager.createSession({ text: "Please review @src/app.ts", fileReferences }); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const readSystemMessage = messages.find( + (message) => message.role === "system" && message.content?.includes("") + ); + + assert.deepEqual(userMessage?.fileReferences, fileReferences); + assert.equal(readSystemMessage?.visible, false); + assert.match(readSystemMessage?.content ?? "", /DeepCode loaded the referenced file by running the Read tool/); + assert.match(readSystemMessage?.content ?? "", /"name": "read"/); + assert.match(readSystemMessage?.content ?? "", /export const value = 1;/); + assert.match(readSystemMessage?.content ?? "", /"id": "snippet_1"/); + assert.equal(getSnippet(sessionId, "snippet_1")?.filePath, filePath); +}); + test("SessionManager preserves empty reasoning content on assistant tool calls", () => { const manager = new SessionManager({ projectRoot: process.cwd(), diff --git a/src/ui/index.ts b/src/ui/index.ts index 9d11fbe..4e2eacc 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -82,7 +82,6 @@ export { export { findExpandedThinkingId } from "./thinkingState"; export { buildExitSummaryText } from "./exitSummary"; export { - buildPromptTextWithFileReferences, parsePromptFileReferenceTokens, resolvePromptFileReferences, type PromptFileReference,