diff --git a/apps/server/package.json b/apps/server/package.json index 6d698fe568..a2113bf4b2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,7 @@ "dev": "OPENWORK_DEV_MODE=1 bun src/cli.ts", "test": "bun test", "test:artifacts": "bun test src/artifact-files.e2e.test.ts src/workspace-init.test.ts src/server.normalizeWorkspaceRelativePath.test.ts", - "build": "tsc -p tsconfig.json && bun build src/opencode-plugins/openwork-extensions-preview.ts src/opencode-plugins/openwork-capabilities-knowledge.ts src/opencode-plugins/openwork-anthropic-adaptive-thinking.ts src/opencode-plugins/openwork-anthropic-tool-schema.ts --outdir dist/opencode-plugins --target node --format esm", + "build": "tsc -p tsconfig.json && bun build src/opencode-plugins/openwork-extensions-preview.ts src/opencode-plugins/openwork-capabilities-knowledge.ts src/opencode-plugins/openwork-anthropic-adaptive-thinking.ts src/opencode-plugins/openwork-anthropic-tool-schema.ts src/opencode-plugins/openwork-system-prompt-normalizer.ts --outdir dist/opencode-plugins --target node --format esm", "build:bin": "bun build --compile src/cli.ts --outfile dist/bin/openwork-server", "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-linux-arm64 --target bun-windows-x64", "start": "bun dist/cli.js", diff --git a/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.test.ts b/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.test.ts index dc905a6b91..583e818d0a 100644 --- a/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.test.ts +++ b/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.test.ts @@ -3,6 +3,23 @@ import { resolve } from "node:path"; import { OpenWorkCapabilitiesKnowledge } from "./openwork-capabilities-knowledge.js"; describe("OpenWork capabilities knowledge plugin", () => { + test("adds capabilities knowledge to the system prompt", async () => { + const plugin = await OpenWorkCapabilitiesKnowledge(); + const output = { + system: [ + "You are OpenWork.", + "", + ], + }; + + await plugin["experimental.chat.system.transform"]({}, output); + + expect(output.system).toHaveLength(3); + expect(output.system[0]).toBe("You are OpenWork."); + expect(output.system[2]).toContain("You are running inside OpenWork"); + expect(output.system[2]).toContain("OpenWork product questions"); + }); + test("retrieves Slack connection guidance from bundled docs", async () => { process.env.OPENWORK_DOCS_DIR = resolve(import.meta.dir, "../../../../packages/docs"); diff --git a/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.ts b/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.ts index de06d7092f..7bf81da82b 100644 --- a/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.ts +++ b/apps/server/src/opencode-plugins/openwork-capabilities-knowledge.ts @@ -214,6 +214,13 @@ function excerpt(content: string, query: string): string { export const OpenWorkCapabilitiesKnowledge = async () => ({ "experimental.chat.system.transform": async (_input: unknown, output: { system: string[] }) => { output.system.push(OPENWORK_CAPABILITIES_KNOWLEDGE); + + // Strict OpenAI-compatible proxies reject multiple system messages. + // Keep the knowledge, but send it as one provider-safe system prompt. + // Mutate the array in-place to ensure the framework sees the change + const merged = output.system.filter((entry) => entry.trim().length > 0).join("\n\n"); + output.system.length = 0; + if (merged) output.system.push(merged); }, tool: { openwork_docs_search: { diff --git a/apps/server/src/opencode-plugins/openwork-extensions-preview.test.ts b/apps/server/src/opencode-plugins/openwork-extensions-preview.test.ts new file mode 100644 index 0000000000..11fba64fcf --- /dev/null +++ b/apps/server/src/opencode-plugins/openwork-extensions-preview.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { OpenWorkExtensionsPreview } from "./openwork-extensions-preview.js"; + +describe("OpenWork extensions preview plugin", () => { + test("adds extension guidance to the system prompt", async () => { + const plugin = await OpenWorkExtensionsPreview(); + const output = { + system: ["You are a title generator.", ""], + }; + + await plugin["experimental.chat.system.transform"]({}, output); + + expect(output.system).toHaveLength(4); + expect(output.system[0]).toBe("You are a title generator."); + expect(output.system[2]).toContain("check OpenWork extensions"); + expect(output.system[3]).toContain("openwork_ui_execute_action"); + }); +}); diff --git a/apps/server/src/opencode-plugins/openwork-extensions-preview.ts b/apps/server/src/opencode-plugins/openwork-extensions-preview.ts index e5b04ec198..a9ee5d239d 100644 --- a/apps/server/src/opencode-plugins/openwork-extensions-preview.ts +++ b/apps/server/src/opencode-plugins/openwork-extensions-preview.ts @@ -204,6 +204,13 @@ export const OpenWorkExtensionsPreview = async () => ({ "experimental.chat.system.transform": async (_input: unknown, output: { system: string[] }) => { output.system.push(OPENWORK_EXTENSION_DISCOVERY_INSTRUCTION); output.system.push(OPENWORK_UI_CONTROL_INSTRUCTION); + + // Strict OpenAI-compatible proxies reject multiple system messages. + // Keep the guidance, but send it as one provider-safe system prompt. + // Mutate the array in-place to ensure the framework sees the change + const merged = output.system.filter((entry) => entry.trim().length > 0).join("\n\n"); + output.system.length = 0; + if (merged) output.system.push(merged); }, tool: { openwork_extension_list_actions: { diff --git a/apps/server/src/opencode-plugins/openwork-system-prompt-normalizer.test.ts b/apps/server/src/opencode-plugins/openwork-system-prompt-normalizer.test.ts new file mode 100644 index 0000000000..fc67076701 --- /dev/null +++ b/apps/server/src/opencode-plugins/openwork-system-prompt-normalizer.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { OpenWorkCapabilitiesKnowledge } from "./openwork-capabilities-knowledge.js"; +import { OpenWorkExtensionsPreview } from "./openwork-extensions-preview.js"; +import { mergeSystemPromptsInPlace, OpenWorkSystemPromptNormalizer } from "./openwork-system-prompt-normalizer.js"; + +describe("OpenWork system prompt normalizer", () => { + test("merges multiple system prompts in place", () => { + const system = [" base prompt ", "", " plugin prompt "]; + const original = system; + + mergeSystemPromptsInPlace(system); + + expect(system).toBe(original); + expect(system).toEqual(["base prompt\n\nplugin prompt"]); + }); + + test("runs after OpenWork plugin transforms to produce one provider-safe system prompt", async () => { + const capabilities = await OpenWorkCapabilitiesKnowledge(); + const extensions = await OpenWorkExtensionsPreview(); + const normalizer = await OpenWorkSystemPromptNormalizer(); + const output = { + system: ["You are OpenWork.", ""], + }; + + await capabilities["experimental.chat.system.transform"]({}, output); + await extensions["experimental.chat.system.transform"]({}, output); + expect(output.system.length).toBeGreaterThan(1); + + await normalizer["experimental.chat.system.transform"]({}, output); + + expect(output.system).toHaveLength(1); + expect(output.system[0]).toContain("You are OpenWork."); + expect(output.system[0]).toContain("You are running inside OpenWork"); + expect(output.system[0]).toContain("check OpenWork extensions"); + expect(output.system[0]).toContain("openwork_ui_execute_action"); + }); +}); diff --git a/apps/server/src/opencode-plugins/openwork-system-prompt-normalizer.ts b/apps/server/src/opencode-plugins/openwork-system-prompt-normalizer.ts new file mode 100644 index 0000000000..cb2ac64a88 --- /dev/null +++ b/apps/server/src/opencode-plugins/openwork-system-prompt-normalizer.ts @@ -0,0 +1,20 @@ +type SystemTransformOutput = { + system: string[]; +}; + +export function mergeSystemPromptsInPlace(system: string[]): void { + const merged = system + .map((entry) => entry.trim()) + .filter(Boolean) + .join("\n\n"); + + // Keep the same array reference for hook callers that hold onto it. + system.length = 0; + if (merged) system.push(merged); +} + +export const OpenWorkSystemPromptNormalizer = async () => ({ + "experimental.chat.system.transform": async (_input: unknown, output: SystemTransformOutput) => { + mergeSystemPromptsInPlace(output.system); + }, +}); diff --git a/apps/server/src/openwork-extensions-plugin-path.ts b/apps/server/src/openwork-extensions-plugin-path.ts index 3ccdd808a9..5e5a857d2a 100644 --- a/apps/server/src/openwork-extensions-plugin-path.ts +++ b/apps/server/src/openwork-extensions-plugin-path.ts @@ -27,5 +27,6 @@ export function openworkPluginPath(name: string, here = dirname(fileURLToPath(im export const openworkExtensionsPreviewPluginPath = () => openworkPluginPath("openwork-extensions-preview"); export const openworkCapabilitiesKnowledgePluginPath = () => openworkPluginPath("openwork-capabilities-knowledge"); +export const openworkSystemPromptNormalizerPluginPath = () => openworkPluginPath("openwork-system-prompt-normalizer"); export const openworkAnthropicAdaptiveThinkingPluginPath = () => openworkPluginPath("openwork-anthropic-adaptive-thinking"); export const openworkAnthropicToolSchemaPluginPath = () => openworkPluginPath("openwork-anthropic-tool-schema"); diff --git a/apps/server/src/openwork-runtime-config.ts b/apps/server/src/openwork-runtime-config.ts index 882ce5731b..d59648bf93 100644 --- a/apps/server/src/openwork-runtime-config.ts +++ b/apps/server/src/openwork-runtime-config.ts @@ -18,6 +18,7 @@ import { randomUUID } from "node:crypto"; import { openworkExtensionsPreviewPluginPath, openworkCapabilitiesKnowledgePluginPath, + openworkSystemPromptNormalizerPluginPath, openworkAnthropicAdaptiveThinkingPluginPath, openworkAnthropicToolSchemaPluginPath, } from "./openwork-extensions-plugin-path.js"; @@ -89,6 +90,7 @@ export async function buildOpenworkRuntimeConfigObject( openworkAnthropicAdaptiveThinkingPluginPath(), openworkAnthropicToolSchemaPluginPath(), ...runtimePluginList(runtimeConfig), + openworkSystemPromptNormalizerPluginPath(), ], ...(disabledProviders.length ? { disabled_providers: disabledProviders } : {}), mcp: runtimeMcpMap(runtimeConfig),