diff --git a/packages/opencode/src/session/dynamic-prompt.ts b/packages/opencode/src/session/dynamic-prompt.ts new file mode 100644 index 000000000000..c8e29a58c032 --- /dev/null +++ b/packages/opencode/src/session/dynamic-prompt.ts @@ -0,0 +1,113 @@ +import path from "path" +import os from "os" +import { Instance } from "../project/instance" + +export namespace DynamicPrompt { + export interface Context { + /** Provider ID (e.g., "anthropic", "openai") */ + providerID: string + /** Model ID (e.g., "claude-3-5-sonnet-20241022") */ + modelID: string + /** Current working directory */ + directory: string + /** Project root directory */ + worktree: string + /** Whether the directory is a git repository */ + isGitRepo: boolean + /** Current platform */ + platform: NodeJS.Platform + /** Current username */ + username: string + } + + /** + * Type for the system prompt function that can be exported from TypeScript/JavaScript files + */ + export type SystemPromptFunction = (context: Context) => string | Promise + + /** + * Resolves a prompt string, handling dynamic TypeScript/JavaScript imports if needed + */ + export async function resolve(prompt: string, context: Context): Promise { + // Check if prompt starts with file:// and points to a TS/JS file + if (!prompt.startsWith("file://")) { + return prompt + } + + const filePath = prompt.slice(7) // Remove "file://" prefix + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(context.directory, filePath) + + // Check if the file is a TypeScript or JavaScript file + const ext = path.extname(resolvedPath).toLowerCase() + if (![".ts", ".js", ".mts", ".mjs"].includes(ext)) { + // Not a TS/JS file, read as text (existing behavior) + return await Bun.file(resolvedPath).text() + } + + try { + // Import the TypeScript/JavaScript file + const module = await import(resolvedPath) + + // Try to get the system function (named export) or default export + const systemFunction = module.system || module.default + + if (typeof systemFunction !== "function") { + throw new Error( + `Dynamic prompt file "${resolvedPath}" must export a "system" function or default function that returns a string`, + ) + } + + // Call the function with context and await if it returns a promise + const result = await systemFunction(context) + + if (typeof result !== "string") { + throw new Error(`Dynamic prompt function in "${resolvedPath}" must return a string, got ${typeof result}`) + } + + return result + } catch (error) { + throw new Error( + `Failed to load dynamic prompt from "${resolvedPath}": ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Creates the context object for dynamic prompts + */ + export function createContext(input: { + providerID: string + modelID: string + username?: string + directory?: string + worktree?: string + isGitRepo?: boolean + }): Context { + let directory: string + let worktree: string + let isGitRepo: boolean + + if (input.directory !== undefined && input.worktree !== undefined && input.isGitRepo !== undefined) { + // Use provided values (for testing) + directory = input.directory + worktree = input.worktree + isGitRepo = input.isGitRepo + } else { + // Use Instance values (production) + const project = Instance.project + directory = Instance.directory + worktree = Instance.worktree + isGitRepo = project.vcs === "git" + } + + return { + providerID: input.providerID, + modelID: input.modelID, + directory, + worktree, + isGitRepo, + platform: process.platform, + username: input.username || os.userInfo().username, + } + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 29940ddacca2..09eddc5fe7f5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -27,7 +27,9 @@ import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" +import { DynamicPrompt } from "./dynamic-prompt" import { Plugin } from "../plugin" +import { Config } from "../config/config" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -401,13 +403,35 @@ export namespace SessionPrompt { modelID: string }) { let system = SystemPrompt.header(input.providerID) - system.push( - ...(() => { - if (input.system) return [input.system] - if (input.agent.prompt) return [input.agent.prompt] - return SystemPrompt.provider(input.modelID) - })(), - ) + + // Determine which prompt to use and potentially resolve dynamically + let promptToResolve: string | undefined + if (input.system) { + promptToResolve = input.system + } else if (input.agent.prompt) { + promptToResolve = input.agent.prompt + } + + if (promptToResolve) { + const config = await Config.get() + const context = DynamicPrompt.createContext({ + providerID: input.providerID, + modelID: input.modelID, + username: config.username, + }) + + try { + const resolvedPrompt = await DynamicPrompt.resolve(promptToResolve, context) + system.push(resolvedPrompt) + } catch (error) { + log.error("failed to resolve dynamic prompt", { error: error instanceof Error ? error.message : String(error) }) + // Keep the original prompt if dynamic resolution fails + system.push(promptToResolve) + } + } else { + system.push(...SystemPrompt.provider(input.modelID)) + } + system.push(...(await SystemPrompt.environment())) system.push(...(await SystemPrompt.custom())) // max 2 system prompt messages for caching purposes diff --git a/packages/opencode/test/session/dynamic-prompt.test.ts b/packages/opencode/test/session/dynamic-prompt.test.ts new file mode 100644 index 000000000000..4304ac284d3b --- /dev/null +++ b/packages/opencode/test/session/dynamic-prompt.test.ts @@ -0,0 +1,239 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { DynamicPrompt } from "../../src/session/dynamic-prompt" +import { mkdtemp, rm } from "fs/promises" +import path from "path" +import os from "os" + +let tempDir: string + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "opencode-dynamic-prompt-test-")) +}) + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +const mockContext: DynamicPrompt.Context = { + providerID: "anthropic", + modelID: "claude-3-5-sonnet-20241022", + directory: "/test/dir", + worktree: "/test/worktree", + isGitRepo: true, + platform: "darwin", + username: "testuser", +} + +test("DynamicPrompt.resolve should return static prompts unchanged", async () => { + const staticPrompt = "This is a static prompt" + const result = await DynamicPrompt.resolve(staticPrompt, mockContext) + expect(result).toBe(staticPrompt) +}) + +test("DynamicPrompt.resolve should read text files for non-TS/JS file:// paths", async () => { + const textContent = "This is a text file content" + const textFile = path.join(tempDir, "test.txt") + await Bun.write(textFile, textContent) + + const result = await DynamicPrompt.resolve(`file://${textFile}`, mockContext) + expect(result).toBe(textContent) +}) + +test("DynamicPrompt.resolve should execute TypeScript files with system export", async () => { + const tsFile = path.join(tempDir, "test.ts") + await Bun.write( + tsFile, + ` +export function system(context: any): string { + return \`Hello \${context.username} on \${context.platform}\` +} +`, + ) + + const result = await DynamicPrompt.resolve(`file://${tsFile}`, mockContext) + expect(result).toBe("Hello testuser on darwin") +}) + +test("DynamicPrompt.resolve should execute JavaScript files with default export", async () => { + const jsFile = path.join(tempDir, "test.js") + await Bun.write( + jsFile, + ` +export default function(context) { + return \`Provider: \${context.providerID}, Model: \${context.modelID}\` +} +`, + ) + + const result = await DynamicPrompt.resolve(`file://${jsFile}`, mockContext) + expect(result).toBe("Provider: anthropic, Model: claude-3-5-sonnet-20241022") +}) + +test("DynamicPrompt.resolve should handle async functions", async () => { + const tsFile = path.join(tempDir, "async-test.ts") + await Bun.write( + tsFile, + ` +export async function system(context: any): Promise { + await new Promise(resolve => setTimeout(resolve, 1)) + return \`Async result for \${context.username}\` +} +`, + ) + + const result = await DynamicPrompt.resolve(`file://${tsFile}`, mockContext) + expect(result).toBe("Async result for testuser") +}) + +test("DynamicPrompt.resolve should throw error for non-existent files", async () => { + const nonExistentFile = path.join(tempDir, "non-existent.ts") + + await expect(DynamicPrompt.resolve(`file://${nonExistentFile}`, mockContext)).rejects.toThrow( + "Failed to load dynamic prompt", + ) +}) + +test("DynamicPrompt.resolve should throw error when no function is exported", async () => { + const tsFile = path.join(tempDir, "no-function.ts") + await Bun.write( + tsFile, + ` +export const notAFunction = "This is not a function" +`, + ) + + await expect(DynamicPrompt.resolve(`file://${tsFile}`, mockContext)).rejects.toThrow( + 'must export a "system" function or default function', + ) +}) + +test("DynamicPrompt.resolve should throw error when function returns non-string", async () => { + const tsFile = path.join(tempDir, "wrong-return.ts") + await Bun.write( + tsFile, + ` +export function system() { + return 123 +} +`, + ) + + await expect(DynamicPrompt.resolve(`file://${tsFile}`, mockContext)).rejects.toThrow( + "must return a string, got number", + ) +}) + +test("DynamicPrompt.resolve should handle .mjs files", async () => { + const mjsFile = path.join(tempDir, "test.mjs") + await Bun.write( + mjsFile, + ` +export default function(context) { + return \`MJS file for \${context.username}\` +} +`, + ) + + const result = await DynamicPrompt.resolve(`file://${mjsFile}`, mockContext) + expect(result).toBe("MJS file for testuser") +}) + +test("DynamicPrompt.resolve should handle .mts files", async () => { + const mtsFile = path.join(tempDir, "test.mts") + await Bun.write( + mtsFile, + ` +export function system(context: any): string { + return \`MTS file for \${context.username}\` +} +`, + ) + + const result = await DynamicPrompt.resolve(`file://${mtsFile}`, mockContext) + expect(result).toBe("MTS file for testuser") +}) + +test("DynamicPrompt.resolve should handle relative file paths", async () => { + const tsFile = path.join(tempDir, "relative.ts") + await Bun.write( + tsFile, + ` +export function system(context: any): string { + return \`Relative path test\` +} +`, + ) + + // Test with a context that has the tempDir as the directory + const relativeContext = { ...mockContext, directory: tempDir } + const result = await DynamicPrompt.resolve("file://relative.ts", relativeContext) + expect(result).toBe("Relative path test") +}) + +test("DynamicPrompt.createContext should create proper context object", () => { + const input = { + providerID: "openai", + modelID: "gpt-4", + username: "customuser", + directory: "/test/directory", + worktree: "/test/worktree", + isGitRepo: true, + } + + const context = DynamicPrompt.createContext(input) + + expect(context.providerID).toBe("openai") + expect(context.modelID).toBe("gpt-4") + expect(context.username).toBe("customuser") + expect(context.directory).toBe("/test/directory") + expect(context.worktree).toBe("/test/worktree") + expect(context.isGitRepo).toBe(true) + expect(context.platform).toBe(process.platform) +}) + +test("DynamicPrompt.createContext should use default username when not provided", () => { + const input = { + providerID: "openai", + modelID: "gpt-4", + directory: "/test/directory", + worktree: "/test/worktree", + isGitRepo: false, + } + + const context = DynamicPrompt.createContext(input) + + expect(context.username).toBe(os.userInfo().username) +}) + +test("DynamicPrompt.resolve should pass all context properties correctly", async () => { + const tsFile = path.join(tempDir, "context-test.ts") + await Bun.write( + tsFile, + ` +export function system(context: any): string { + return JSON.stringify({ + providerID: context.providerID, + modelID: context.modelID, + directory: context.directory, + worktree: context.worktree, + isGitRepo: context.isGitRepo, + platform: context.platform, + username: context.username, + }) +} +`, + ) + + const result = await DynamicPrompt.resolve(`file://${tsFile}`, mockContext) + const parsed = JSON.parse(result) + + expect(parsed.providerID).toBe(mockContext.providerID) + expect(parsed.modelID).toBe(mockContext.modelID) + expect(parsed.directory).toBe(mockContext.directory) + expect(parsed.worktree).toBe(mockContext.worktree) + expect(parsed.isGitRepo).toBe(mockContext.isGitRepo) + expect(parsed.platform).toBe(mockContext.platform) + expect(parsed.username).toBe(mockContext.username) +}) diff --git a/packages/opencode/test/session/prompt-integration.test.ts b/packages/opencode/test/session/prompt-integration.test.ts new file mode 100644 index 000000000000..d792d9067dab --- /dev/null +++ b/packages/opencode/test/session/prompt-integration.test.ts @@ -0,0 +1,215 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { mkdtemp, rm } from "fs/promises" +import path from "path" +import os from "os" +import { Agent } from "../../src/agent/agent" + +// Note: This is an integration test that requires proper setup of the session context +// For now, we'll test the dynamic prompt integration by mocking the necessary parts + +let tempDir: string + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "opencode-prompt-integration-test-")) +}) + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +test("Agent configuration should handle dynamic prompts in JSON format", async () => { + // Create a dynamic prompt file + const dynamicPromptFile = path.join(tempDir, "agent-prompt.ts") + await Bun.write( + dynamicPromptFile, + ` +export function system(context) { + return \`Dynamic agent for \${context.providerID} provider\` +} +`, + ) + + // Create agent configuration that references the dynamic prompt + const agentConfig: Agent.Info = { + name: "test-dynamic", + mode: "all", + builtIn: false, + permission: { + edit: "allow", + bash: { "*": "allow" }, + webfetch: "allow", + }, + prompt: `file://${dynamicPromptFile}`, + tools: {}, + options: {}, + } + + // Verify the agent has the correct prompt path + expect(agentConfig.prompt).toBe(`file://${dynamicPromptFile}`) + expect(agentConfig.prompt?.startsWith("file://")).toBe(true) + expect(agentConfig.prompt?.endsWith(".ts")).toBe(true) +}) + +test("Markdown agent configuration should support dynamic prompts in frontmatter", async () => { + // Create a dynamic prompt file + const dynamicPromptFile = path.join(tempDir, "markdown-prompt.js") + await Bun.write( + dynamicPromptFile, + ` +export default function(context) { + return \`Markdown dynamic agent for \${context.username}\` +} +`, + ) + + // Create markdown agent file with frontmatter pointing to dynamic prompt + const markdownAgent = path.join(tempDir, "test-agent.md") + await Bun.write( + markdownAgent, + `--- +description: "Test agent with dynamic prompt" +prompt: "file://${dynamicPromptFile}" +tools: + read: true + write: true +--- + +This markdown content will be ignored because the frontmatter has a prompt field.`, + ) + + // Simulate what the config loader would do + const matter = await import("gray-matter") + const content = await Bun.file(markdownAgent).text() + const parsed = matter.default(content) + + const agentConfig = { + name: "test-agent", + ...parsed.data, + prompt: parsed.data.prompt || parsed.content.trim(), + } + + // Verify the configuration + expect(agentConfig.prompt).toBe(`file://${dynamicPromptFile}`) + expect(agentConfig.description).toBe("Test agent with dynamic prompt") + expect(agentConfig.tools).toEqual({ read: true, write: true }) +}) + +test("Dynamic prompt should work with various file extensions", async () => { + const extensions = [".ts", ".js", ".mts", ".mjs"] + + for (const ext of extensions) { + const promptFile = path.join(tempDir, `prompt${ext}`) + const isTypeScript = ext.includes("ts") + + await Bun.write( + promptFile, + ` +export function system(context${isTypeScript ? ": any" : ""}) { + return \`Extension ${ext} works for \${context.username}\` +} +`, + ) + + const agentConfig: Agent.Info = { + name: `test-${ext.slice(1)}`, + mode: "all", + builtIn: false, + permission: { + edit: "allow", + bash: { "*": "allow" }, + webfetch: "allow", + }, + prompt: `file://${promptFile}`, + tools: {}, + options: {}, + } + + expect(agentConfig.prompt).toBe(`file://${promptFile}`) + } +}) + +test("Error handling should work correctly for malformed dynamic prompts", async () => { + // Test non-existent file + const nonExistentFile = path.join(tempDir, "non-existent.ts") + const agentConfig1: Agent.Info = { + name: "test-non-existent", + mode: "all", + builtIn: false, + permission: { edit: "allow", bash: { "*": "allow" }, webfetch: "allow" }, + prompt: `file://${nonExistentFile}`, + tools: {}, + options: {}, + } + expect(agentConfig1.prompt).toBe(`file://${nonExistentFile}`) + + // Test file with no function export + const noFunctionFile = path.join(tempDir, "no-function.ts") + await Bun.write(noFunctionFile, `export const notAFunction = "hello"`) + + const agentConfig2: Agent.Info = { + name: "test-no-function", + mode: "all", + builtIn: false, + permission: { edit: "allow", bash: { "*": "allow" }, webfetch: "allow" }, + prompt: `file://${noFunctionFile}`, + tools: {}, + options: {}, + } + expect(agentConfig2.prompt).toBe(`file://${noFunctionFile}`) + + // Test file with wrong return type + const wrongReturnFile = path.join(tempDir, "wrong-return.ts") + await Bun.write(wrongReturnFile, `export function system() { return 123 }`) + + const agentConfig3: Agent.Info = { + name: "test-wrong-return", + mode: "all", + builtIn: false, + permission: { edit: "allow", bash: { "*": "allow" }, webfetch: "allow" }, + prompt: `file://${wrongReturnFile}`, + tools: {}, + options: {}, + } + expect(agentConfig3.prompt).toBe(`file://${wrongReturnFile}`) +}) + +test("Mixed static and dynamic prompts should coexist", async () => { + // Static prompt agent + const staticAgent: Agent.Info = { + name: "static-agent", + mode: "all", + builtIn: false, + permission: { edit: "allow", bash: { "*": "allow" }, webfetch: "allow" }, + prompt: "This is a static prompt", + tools: {}, + options: {}, + } + + // Dynamic prompt agent + const dynamicPromptFile = path.join(tempDir, "dynamic.ts") + await Bun.write( + dynamicPromptFile, + ` +export function system(context) { + return \`This is a dynamic prompt for \${context.username}\` +} +`, + ) + + const dynamicAgent: Agent.Info = { + name: "dynamic-agent", + mode: "all", + builtIn: false, + permission: { edit: "allow", bash: { "*": "allow" }, webfetch: "allow" }, + prompt: `file://${dynamicPromptFile}`, + tools: {}, + options: {}, + } + + expect(staticAgent.prompt).toBe("This is a static prompt") + expect(dynamicAgent.prompt).toBe(`file://${dynamicPromptFile}`) + expect(staticAgent.prompt?.startsWith("file://")).toBe(false) + expect(dynamicAgent.prompt?.startsWith("file://")).toBe(true) +})