feat(FN-1423): add promptOverrides to project settings
- Add promptOverrides configuration to project settings with null-as-delete semantics - Add core prompt-key catalog with resolver primitives in packages/core/src/prompt-overrides.ts - Export prompt key catalog, resolution functions, and settings merge helpers from @fusion/core - Add parity tests for promptOverrides settings handling - Add settings export/import coverage for promptOverrides - Add settings export tests verifying promptOverrides round-trip - Update settings reference documentation with promptOverrides section - Add store methods for loading/exporting project settings with prompt overrides
This commit is contained in:
@@ -123,6 +123,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `runStepsInNewSessions` | `boolean` | `false` | Run each task step in a fresh agent session. |
|
| `runStepsInNewSessions` | `boolean` | `false` | Run each task step in a fresh agent session. |
|
||||||
| `maxParallelSteps` | `number` | `2` | Max concurrent step sessions (1–4). |
|
| `maxParallelSteps` | `number` | `2` | Max concurrent step sessions (1–4). |
|
||||||
| `agentPrompts` | `object` | `undefined` | Custom agent prompt templates + role assignments. |
|
| `agentPrompts` | `object` | `undefined` | Custom agent prompt templates + role assignments. |
|
||||||
|
| `promptOverrides` | `Record<string, string>` | `undefined` | Fine-grained prompt segment overrides (e.g., `{"executor-welcome": "..."}`). |
|
||||||
|
|
||||||
### Additional ProjectSettings fields
|
### Additional ProjectSettings fields
|
||||||
|
|
||||||
@@ -159,6 +160,67 @@ These exist in `ProjectSettings` but are not part of `PROJECT_SETTINGS_KEYS`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Prompt Overrides
|
||||||
|
|
||||||
|
Fusion supports fine-grained customization of AI agent prompts through the `promptOverrides` setting. This enables surgical customization of specific prompt segments without replacing entire role prompts (which `agentPrompts` does).
|
||||||
|
|
||||||
|
### Supported Prompt Keys
|
||||||
|
|
||||||
|
| Key | Agent Role | Description |
|
||||||
|
|-----|-----------|-------------|
|
||||||
|
| `executor-welcome` | executor | Introductory section for the executor agent |
|
||||||
|
| `executor-guardrails` | executor | Behavioral guardrails and constraints |
|
||||||
|
| `executor-spawning` | executor | Instructions for spawning child agents |
|
||||||
|
| `executor-completion` | executor | Completion criteria and signaling |
|
||||||
|
| `triage-welcome` | triage | Introductory section for the triage/specification agent |
|
||||||
|
| `triage-context` | triage | Context-gathering instructions |
|
||||||
|
| `reviewer-verdict` | reviewer | Verdict criteria and format |
|
||||||
|
| `merger-conflicts` | merger | Merge conflict resolution instructions |
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Override Selection**: When a prompt key is present with a non-empty value, that override replaces the default prompt segment.
|
||||||
|
|
||||||
|
2. **Fallback to Defaults**: Missing or empty values fall back to the built-in default content.
|
||||||
|
|
||||||
|
3. **Cascade**: `agentPrompts` provides full-role template customization, while `promptOverrides` provides segment-level customization. Both can be used together — `promptOverrides` applies to the segment even within a custom role template.
|
||||||
|
|
||||||
|
### Clearing Overrides
|
||||||
|
|
||||||
|
To clear a specific override, set it to `null`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"promptOverrides": {
|
||||||
|
"executor-welcome": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To clear all overrides, set `promptOverrides` to `null`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"promptOverrides": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"promptOverrides": {
|
||||||
|
"executor-welcome": "Custom executor welcome message for this project...",
|
||||||
|
"executor-guardrails": "## Custom Guardrails\n- Project-specific rules...",
|
||||||
|
"triage-welcome": "Custom triage introduction..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## JSON Examples
|
## JSON Examples
|
||||||
|
|
||||||
### 1) Team baseline for reliable automation
|
### 1) Team baseline for reliable automation
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const PROJECT_KEYS: (keyof ProjectSettings)[] = [
|
|||||||
"missionMaxTaskRetries",
|
"missionMaxTaskRetries",
|
||||||
"missionHealthCheckIntervalMs",
|
"missionHealthCheckIntervalMs",
|
||||||
"agentPrompts",
|
"agentPrompts",
|
||||||
|
"promptOverrides",
|
||||||
"reflectionEnabled",
|
"reflectionEnabled",
|
||||||
"reflectionIntervalMs",
|
"reflectionIntervalMs",
|
||||||
"reflectionAfterTask",
|
"reflectionAfterTask",
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ export {
|
|||||||
getAvailableTemplates,
|
getAvailableTemplates,
|
||||||
getTemplatesForRole,
|
getTemplatesForRole,
|
||||||
} from "./agent-prompts.js";
|
} from "./agent-prompts.js";
|
||||||
|
|
||||||
|
// ── Prompt Overrides ─────────────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
PROMPT_KEY_CATALOG,
|
||||||
|
resolvePrompt,
|
||||||
|
resolveRolePrompts,
|
||||||
|
hasRoleOverrides,
|
||||||
|
getOverriddenKeys,
|
||||||
|
clearOverrides,
|
||||||
|
getPromptKeyMetadata,
|
||||||
|
getPromptKeysForRole,
|
||||||
|
isValidPromptKey,
|
||||||
|
isValidPromptOverrideMap,
|
||||||
|
assertValidPromptOverrideMap,
|
||||||
|
} from "./prompt-overrides.js";
|
||||||
|
export type {
|
||||||
|
PromptKey,
|
||||||
|
PromptKeyMetadata,
|
||||||
|
PromptKeyCatalog,
|
||||||
|
PromptOverrideEntry,
|
||||||
|
PromptOverrideMap,
|
||||||
|
} from "./prompt-overrides.js";
|
||||||
export {
|
export {
|
||||||
ROLE_DEFAULT_PERMISSIONS,
|
ROLE_DEFAULT_PERMISSIONS,
|
||||||
normalizePermissions,
|
normalizePermissions,
|
||||||
|
|||||||
377
packages/core/src/prompt-overrides.test.ts
Normal file
377
packages/core/src/prompt-overrides.test.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
PROMPT_KEY_CATALOG,
|
||||||
|
PromptKey,
|
||||||
|
PromptOverrideMap,
|
||||||
|
resolvePrompt,
|
||||||
|
resolveRolePrompts,
|
||||||
|
hasRoleOverrides,
|
||||||
|
getOverriddenKeys,
|
||||||
|
clearOverrides,
|
||||||
|
getPromptKeyMetadata,
|
||||||
|
getPromptKeysForRole,
|
||||||
|
isValidPromptKey,
|
||||||
|
isValidPromptOverrideMap,
|
||||||
|
assertValidPromptOverrideMap,
|
||||||
|
} from "./prompt-overrides.js";
|
||||||
|
|
||||||
|
describe("prompt-overrides", () => {
|
||||||
|
describe("PROMPT_KEY_CATALOG", () => {
|
||||||
|
it("should contain all expected prompt keys", () => {
|
||||||
|
const expectedKeys: PromptKey[] = [
|
||||||
|
"executor-welcome",
|
||||||
|
"executor-guardrails",
|
||||||
|
"executor-spawning",
|
||||||
|
"executor-completion",
|
||||||
|
"triage-welcome",
|
||||||
|
"triage-context",
|
||||||
|
"reviewer-verdict",
|
||||||
|
"merger-conflicts",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of expectedKeys) {
|
||||||
|
expect(PROMPT_KEY_CATALOG).toHaveProperty(key);
|
||||||
|
expect(PROMPT_KEY_CATALOG[key].key).toBe(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid metadata for each key", () => {
|
||||||
|
for (const [key, meta] of Object.entries(PROMPT_KEY_CATALOG)) {
|
||||||
|
expect(meta.key).toBe(key);
|
||||||
|
expect(typeof meta.name).toBe("string");
|
||||||
|
expect(meta.name.length).toBeGreaterThan(0);
|
||||||
|
expect(Array.isArray(meta.roles)).toBe(true);
|
||||||
|
expect(meta.roles.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof meta.description).toBe("string");
|
||||||
|
expect(typeof meta.defaultContent).toBe("string");
|
||||||
|
expect(meta.defaultContent.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have appropriate roles for each key", () => {
|
||||||
|
// Executor keys should only be for executor role
|
||||||
|
expect(PROMPT_KEY_CATALOG["executor-welcome"].roles).toContain("executor");
|
||||||
|
expect(PROMPT_KEY_CATALOG["executor-guardrails"].roles).toContain("executor");
|
||||||
|
expect(PROMPT_KEY_CATALOG["executor-spawning"].roles).toContain("executor");
|
||||||
|
expect(PROMPT_KEY_CATALOG["executor-completion"].roles).toContain("executor");
|
||||||
|
|
||||||
|
// Triage keys should only be for triage role
|
||||||
|
expect(PROMPT_KEY_CATALOG["triage-welcome"].roles).toContain("triage");
|
||||||
|
expect(PROMPT_KEY_CATALOG["triage-context"].roles).toContain("triage");
|
||||||
|
|
||||||
|
// Reviewer and merger keys
|
||||||
|
expect(PROMPT_KEY_CATALOG["reviewer-verdict"].roles).toContain("reviewer");
|
||||||
|
expect(PROMPT_KEY_CATALOG["merger-conflicts"].roles).toContain("merger");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPromptKeyMetadata", () => {
|
||||||
|
it("should return metadata for valid keys", () => {
|
||||||
|
const meta = getPromptKeyMetadata("executor-welcome");
|
||||||
|
expect(meta).toBeDefined();
|
||||||
|
expect(meta?.key).toBe("executor-welcome");
|
||||||
|
expect(meta?.name).toBe("Executor Welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for invalid keys", () => {
|
||||||
|
expect(getPromptKeyMetadata("invalid-key" as PromptKey)).toBeUndefined();
|
||||||
|
expect(getPromptKeyMetadata("" as PromptKey)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPromptKeysForRole", () => {
|
||||||
|
it("should return all keys for executor role", () => {
|
||||||
|
const keys = getPromptKeysForRole("executor");
|
||||||
|
expect(keys).toHaveLength(4);
|
||||||
|
expect(keys.map((k) => k.key)).toContain("executor-welcome");
|
||||||
|
expect(keys.map((k) => k.key)).toContain("executor-guardrails");
|
||||||
|
expect(keys.map((k) => k.key)).toContain("executor-spawning");
|
||||||
|
expect(keys.map((k) => k.key)).toContain("executor-completion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all keys for triage role", () => {
|
||||||
|
const keys = getPromptKeysForRole("triage");
|
||||||
|
expect(keys).toHaveLength(2);
|
||||||
|
expect(keys.map((k) => k.key)).toContain("triage-welcome");
|
||||||
|
expect(keys.map((k) => k.key)).toContain("triage-context");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return single key for reviewer role", () => {
|
||||||
|
const keys = getPromptKeysForRole("reviewer");
|
||||||
|
expect(keys).toHaveLength(1);
|
||||||
|
expect(keys[0].key).toBe("reviewer-verdict");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return single key for merger role", () => {
|
||||||
|
const keys = getPromptKeysForRole("merger");
|
||||||
|
expect(keys).toHaveLength(1);
|
||||||
|
expect(keys[0].key).toBe("merger-conflicts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePrompt", () => {
|
||||||
|
it("should return override when present and non-empty", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom executor welcome",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolvePrompt("executor-welcome", overrides);
|
||||||
|
expect(result).toBe("Custom executor welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default when no override present", () => {
|
||||||
|
const overrides: PromptOverrideMap = {};
|
||||||
|
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
|
||||||
|
|
||||||
|
const result = resolvePrompt("executor-welcome", overrides);
|
||||||
|
expect(result).toBe(defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default when override is empty string", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "",
|
||||||
|
};
|
||||||
|
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
|
||||||
|
|
||||||
|
const result = resolvePrompt("executor-welcome", overrides);
|
||||||
|
expect(result).toBe(defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default when override is undefined", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": undefined,
|
||||||
|
};
|
||||||
|
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
|
||||||
|
|
||||||
|
const result = resolvePrompt("executor-welcome", overrides);
|
||||||
|
expect(result).toBe(defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default when overrides is undefined", () => {
|
||||||
|
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
|
||||||
|
|
||||||
|
const result = resolvePrompt("executor-welcome", undefined);
|
||||||
|
expect(result).toBe(defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default for unrecognized key", () => {
|
||||||
|
const result = resolvePrompt("invalid-key" as PromptKey, {});
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty string for unrecognized key with no defaults", () => {
|
||||||
|
const result = resolvePrompt("invalid-key" as PromptKey, undefined);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRolePrompts", () => {
|
||||||
|
it("should resolve all prompts for executor role", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom welcome",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveRolePrompts("executor", overrides);
|
||||||
|
|
||||||
|
// Custom override
|
||||||
|
expect(result["executor-welcome"]).toBe("Custom welcome");
|
||||||
|
|
||||||
|
// Defaults for others
|
||||||
|
expect(result["executor-guardrails"]).toBe(PROMPT_KEY_CATALOG["executor-guardrails"].defaultContent);
|
||||||
|
expect(result["executor-spawning"]).toBe(PROMPT_KEY_CATALOG["executor-spawning"].defaultContent);
|
||||||
|
expect(result["executor-completion"]).toBe(PROMPT_KEY_CATALOG["executor-completion"].defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve all prompts for triage role", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"triage-welcome": "Custom triage welcome",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveRolePrompts("triage", overrides);
|
||||||
|
|
||||||
|
expect(result["triage-welcome"]).toBe("Custom triage welcome");
|
||||||
|
expect(result["triage-context"]).toBe(PROMPT_KEY_CATALOG["triage-context"].defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all defaults when no overrides", () => {
|
||||||
|
const result = resolveRolePrompts("executor", undefined);
|
||||||
|
|
||||||
|
for (const [key, content] of Object.entries(result)) {
|
||||||
|
expect(content).toBe(PROMPT_KEY_CATALOG[key as PromptKey].defaultContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty object for roles with no prompts", () => {
|
||||||
|
// Scheduler and custom roles have no defined prompts
|
||||||
|
const result = resolveRolePrompts("scheduler" as any, {});
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasRoleOverrides", () => {
|
||||||
|
it("should return true when at least one override is set", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(hasRoleOverrides("executor", overrides)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when no overrides set", () => {
|
||||||
|
expect(hasRoleOverrides("executor", {})).toBe(false);
|
||||||
|
expect(hasRoleOverrides("executor", undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when overrides are empty strings", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(hasRoleOverrides("executor", overrides)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when only other role has overrides", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"triage-welcome": "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(hasRoleOverrides("executor", overrides)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOverriddenKeys", () => {
|
||||||
|
it("should return keys with non-empty overrides", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom",
|
||||||
|
"triage-welcome": "Custom triage",
|
||||||
|
"merger-conflicts": "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getOverriddenKeys(overrides);
|
||||||
|
expect(result).toContain("executor-welcome");
|
||||||
|
expect(result).toContain("triage-welcome");
|
||||||
|
expect(result).not.toContain("merger-conflicts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for undefined", () => {
|
||||||
|
expect(getOverriddenKeys(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for empty object", () => {
|
||||||
|
expect(getOverriddenKeys({})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearOverrides", () => {
|
||||||
|
it("should remove specified keys", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom",
|
||||||
|
"executor-guardrails": "Custom guardrails",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = clearOverrides(overrides, ["executor-welcome"]);
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("executor-welcome");
|
||||||
|
expect(result?.["executor-guardrails"]).toBe("Custom guardrails");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when all keys are cleared", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = clearOverrides(overrides, ["executor-welcome"]);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined input", () => {
|
||||||
|
const result = clearOverrides(undefined, ["executor-welcome"]);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve other keys when clearing", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Custom",
|
||||||
|
"triage-welcome": "Custom triage",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = clearOverrides(overrides, ["executor-welcome"]);
|
||||||
|
|
||||||
|
expect(result?.["triage-welcome"]).toBe("Custom triage");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidPromptKey", () => {
|
||||||
|
it("should return true for valid keys", () => {
|
||||||
|
expect(isValidPromptKey("executor-welcome")).toBe(true);
|
||||||
|
expect(isValidPromptKey("merger-conflicts")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for invalid keys", () => {
|
||||||
|
expect(isValidPromptKey("invalid")).toBe(false);
|
||||||
|
expect(isValidPromptKey("")).toBe(false);
|
||||||
|
expect(isValidPromptKey(123 as any)).toBe(false);
|
||||||
|
expect(isValidPromptKey(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidPromptOverrideMap", () => {
|
||||||
|
it("should return true for valid maps", () => {
|
||||||
|
expect(isValidPromptOverrideMap({})).toBe(true);
|
||||||
|
expect(isValidPromptOverrideMap({ "executor-welcome": "Custom" })).toBe(true);
|
||||||
|
expect(isValidPromptOverrideMap({ "executor-welcome": undefined })).toBe(true);
|
||||||
|
expect(isValidPromptOverrideMap({ "executor-welcome": "", "triage-welcome": "Custom" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for invalid values", () => {
|
||||||
|
expect(isValidPromptOverrideMap(null)).toBe(false);
|
||||||
|
expect(isValidPromptOverrideMap("string")).toBe(false);
|
||||||
|
expect(isValidPromptOverrideMap(123)).toBe(false);
|
||||||
|
expect(isValidPromptOverrideMap({ "invalid-key": "value" })).toBe(false);
|
||||||
|
expect(isValidPromptOverrideMap({ "executor-welcome": 123 as any })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertValidPromptOverrideMap", () => {
|
||||||
|
it("should not throw for valid maps", () => {
|
||||||
|
expect(() => assertValidPromptOverrideMap({})).not.toThrow();
|
||||||
|
expect(() => assertValidPromptOverrideMap({ "executor-welcome": "Custom" })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for invalid maps", () => {
|
||||||
|
expect(() => assertValidPromptOverrideMap(null)).toThrow();
|
||||||
|
expect(() => assertValidPromptOverrideMap({ "invalid": "value" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fallback behavior", () => {
|
||||||
|
it("should always return a string (never undefined or throw)", () => {
|
||||||
|
// All valid keys should return strings
|
||||||
|
for (const key of Object.keys(PROMPT_KEY_CATALOG) as PromptKey[]) {
|
||||||
|
expect(typeof resolvePrompt(key, undefined)).toBe("string");
|
||||||
|
expect(typeof resolvePrompt(key, {})).toBe("string");
|
||||||
|
expect(typeof resolvePrompt(key, { [key]: undefined })).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle partial overrides gracefully", () => {
|
||||||
|
const overrides: PromptOverrideMap = {
|
||||||
|
"executor-welcome": "Only this is overridden",
|
||||||
|
// Other keys intentionally not set
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
const result = resolveRolePrompts("executor", overrides);
|
||||||
|
|
||||||
|
// Overridden key should have custom value
|
||||||
|
expect(result["executor-welcome"]).toBe("Only this is overridden");
|
||||||
|
|
||||||
|
// Other keys should have defaults
|
||||||
|
expect(result["executor-guardrails"]).toBe(PROMPT_KEY_CATALOG["executor-guardrails"].defaultContent);
|
||||||
|
expect(result["executor-spawning"]).toBe(PROMPT_KEY_CATALOG["executor-spawning"].defaultContent);
|
||||||
|
expect(result["executor-completion"]).toBe(PROMPT_KEY_CATALOG["executor-completion"].defaultContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
392
packages/core/src/prompt-overrides.ts
Normal file
392
packages/core/src/prompt-overrides.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* Prompt customization foundation for runtime prompt overrides.
|
||||||
|
*
|
||||||
|
* This module provides:
|
||||||
|
* - A typed catalog of stable prompt-key identifiers
|
||||||
|
* - Metadata structures with default prompt content
|
||||||
|
* - Resolver functions that return override text when present, otherwise fall back to defaults
|
||||||
|
* - Deterministic fallback behavior for missing/invalid entries
|
||||||
|
*
|
||||||
|
* Runtime packages can use these APIs to resolve prompt text by stable keys,
|
||||||
|
* enabling project-level customization of AI agent prompts without modifying
|
||||||
|
* the core prompt templates in agent-prompts.ts.
|
||||||
|
*
|
||||||
|
* @module prompt-overrides
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentCapability } from "./types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt Key Catalog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable identifier for a customizable prompt segment.
|
||||||
|
*
|
||||||
|
* Each key represents a discrete portion of an agent's prompt that can be
|
||||||
|
* independently overridden at the project level via settings.
|
||||||
|
*/
|
||||||
|
export type PromptKey =
|
||||||
|
| "executor-welcome"
|
||||||
|
| "executor-guardrails"
|
||||||
|
| "executor-spawning"
|
||||||
|
| "executor-completion"
|
||||||
|
| "triage-welcome"
|
||||||
|
| "triage-context"
|
||||||
|
| "reviewer-verdict"
|
||||||
|
| "merger-conflicts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata describing a prompt key including its purpose and default content.
|
||||||
|
*/
|
||||||
|
export interface PromptKeyMetadata {
|
||||||
|
/** Stable key identifier */
|
||||||
|
key: PromptKey;
|
||||||
|
/** Human-readable name for UI display */
|
||||||
|
name: string;
|
||||||
|
/** Which agent role(s) this prompt applies to */
|
||||||
|
roles: AgentCapability[];
|
||||||
|
/** Short description of what this prompt segment controls */
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Default prompt content.
|
||||||
|
* Runtime packages should use resolvePrompt() to get the effective content
|
||||||
|
* (override if present in settings, otherwise this default).
|
||||||
|
*/
|
||||||
|
defaultContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of prompt key to its metadata.
|
||||||
|
*/
|
||||||
|
export type PromptKeyCatalog = Record<PromptKey, PromptKeyMetadata>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Built-in Prompt Key Metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in metadata catalog for all supported prompt keys.
|
||||||
|
* Each entry describes a customizable prompt segment with its default content.
|
||||||
|
*/
|
||||||
|
export const PROMPT_KEY_CATALOG: PromptKeyCatalog = {
|
||||||
|
"executor-welcome": {
|
||||||
|
key: "executor-welcome",
|
||||||
|
name: "Executor Welcome",
|
||||||
|
roles: ["executor"],
|
||||||
|
description: "Introductory section for the executor agent",
|
||||||
|
defaultContent: `You are a task execution agent for "fn", an AI-orchestrated task board.
|
||||||
|
|
||||||
|
You are working in a git worktree isolated from the main branch. Your job is to implement the task described in the PROMPT.md specification you're given.`,
|
||||||
|
},
|
||||||
|
"executor-guardrails": {
|
||||||
|
key: "executor-guardrails",
|
||||||
|
name: "Executor Guardrails",
|
||||||
|
roles: ["executor"],
|
||||||
|
description: "Behavioral guardrails and constraints for the executor",
|
||||||
|
defaultContent: `## Guardrails
|
||||||
|
- Treat the File Scope in PROMPT.md as the expected starting scope, not a hard boundary when quality gates fail
|
||||||
|
- Read "Context to Read First" files before starting
|
||||||
|
- Follow the "Do NOT" section strictly
|
||||||
|
- If tests, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green`,
|
||||||
|
},
|
||||||
|
"executor-spawning": {
|
||||||
|
key: "executor-spawning",
|
||||||
|
name: "Executor Spawning",
|
||||||
|
roles: ["executor"],
|
||||||
|
description: "Instructions for spawning child agents",
|
||||||
|
defaultContent: `## Spawning Child Agents
|
||||||
|
|
||||||
|
You can spawn child agents to handle parallel work or specialized sub-tasks:
|
||||||
|
|
||||||
|
**When to use \`spawn_agent\`:**
|
||||||
|
- Parallel work that can be divided into independent chunks
|
||||||
|
- Specialized tasks requiring different expertise or tools
|
||||||
|
- Delegation of sub-tasks to specialized agents
|
||||||
|
|
||||||
|
**How to spawn:**
|
||||||
|
\`\`\`javascript
|
||||||
|
spawn_agent({
|
||||||
|
name: "researcher",
|
||||||
|
role: "engineer",
|
||||||
|
task: "Research best practices for authentication in React applications"
|
||||||
|
})
|
||||||
|
\`\`\``,
|
||||||
|
},
|
||||||
|
"executor-completion": {
|
||||||
|
key: "executor-completion",
|
||||||
|
name: "Executor Completion",
|
||||||
|
roles: ["executor"],
|
||||||
|
description: "Completion criteria and signaling for executor",
|
||||||
|
defaultContent: `## Completion
|
||||||
|
After all steps are done, tests pass, typecheck passes, and docs are updated:
|
||||||
|
\`\`\`bash
|
||||||
|
Call \`task_done()\` to signal completion.
|
||||||
|
\`\`\``,
|
||||||
|
},
|
||||||
|
"triage-welcome": {
|
||||||
|
key: "triage-welcome",
|
||||||
|
name: "Triage Welcome",
|
||||||
|
roles: ["triage"],
|
||||||
|
description: "Introductory section for the triage/specification agent",
|
||||||
|
defaultContent: `You are a task specification agent for "fn", an AI-orchestrated task board.
|
||||||
|
|
||||||
|
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation.`,
|
||||||
|
},
|
||||||
|
"triage-context": {
|
||||||
|
key: "triage-context",
|
||||||
|
name: "Triage Context",
|
||||||
|
roles: ["triage"],
|
||||||
|
description: "Context-gathering instructions for triage",
|
||||||
|
defaultContent: `## What you receive
|
||||||
|
- A raw task title and optional description (the user's rough idea)
|
||||||
|
- Access to the project's files so you can understand context`,
|
||||||
|
},
|
||||||
|
"reviewer-verdict": {
|
||||||
|
key: "reviewer-verdict",
|
||||||
|
name: "Reviewer Verdict",
|
||||||
|
roles: ["reviewer"],
|
||||||
|
description: "Verdict criteria and format for code/review agent",
|
||||||
|
defaultContent: `## Verdict Criteria
|
||||||
|
|
||||||
|
- **APPROVE** — Step will achieve its stated outcomes. Minor suggestions go in
|
||||||
|
the Suggestions section but do NOT block progress. If your only findings are
|
||||||
|
minor or suggestion-level, verdict is APPROVE.
|
||||||
|
- **REVISE** — Step will fail, produce incorrect results, or miss a stated
|
||||||
|
requirement without fixes. Use ONLY for issues that would cause the worker to
|
||||||
|
redo work later.
|
||||||
|
- **RETHINK** — Approach is fundamentally wrong. Explain why and suggest an
|
||||||
|
alternative.`,
|
||||||
|
},
|
||||||
|
"merger-conflicts": {
|
||||||
|
key: "merger-conflicts",
|
||||||
|
name: "Merger Conflicts",
|
||||||
|
roles: ["merger"],
|
||||||
|
description: "Merge conflict resolution instructions for merger",
|
||||||
|
defaultContent: `## Conflict resolution
|
||||||
|
If there are merge conflicts:
|
||||||
|
1. Run \`git diff --name-only --diff-filter=U\` to list conflicted files
|
||||||
|
2. Read each conflicted file — look for the <<<<<<< / ======= / >>>>>>> markers
|
||||||
|
3. Understand the intent of BOTH sides, then edit the file to produce the correct merged result
|
||||||
|
4. Remove ALL conflict markers — the result must be clean, compilable code
|
||||||
|
5. Run \`git add <file>\` for each resolved file
|
||||||
|
6. Do NOT change anything beyond what's needed to resolve the conflict`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the metadata for a specific prompt key.
|
||||||
|
* Returns undefined if the key is not recognized.
|
||||||
|
*/
|
||||||
|
export function getPromptKeyMetadata(key: PromptKey): PromptKeyMetadata | undefined {
|
||||||
|
return PROMPT_KEY_CATALOG[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all prompt keys for a specific agent role.
|
||||||
|
*/
|
||||||
|
export function getPromptKeysForRole(role: AgentCapability): PromptKeyMetadata[] {
|
||||||
|
return Object.values(PROMPT_KEY_CATALOG).filter((meta) => meta.roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Override Entry Type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single prompt override entry stored in project settings.
|
||||||
|
* The value is the custom prompt content; undefined means "use default".
|
||||||
|
*/
|
||||||
|
export type PromptOverrideEntry = string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of prompt overrides keyed by PromptKey.
|
||||||
|
* Stored in project settings as `promptOverrides: Record<PromptKey, string>`.
|
||||||
|
*/
|
||||||
|
export type PromptOverrideMap = Partial<Record<PromptKey, string>>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolver Functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective prompt content for a given key.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. If `overrides[key]` is a non-empty string, return the override
|
||||||
|
* 2. Otherwise, return the default content from PROMPT_KEY_CATALOG
|
||||||
|
*
|
||||||
|
* @param key - The prompt key to resolve
|
||||||
|
* @param overrides - The project-level overrides map (from settings)
|
||||||
|
* @returns The effective prompt content (override or default)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const overrides: PromptOverrideMap = {
|
||||||
|
* "executor-welcome": "Custom welcome message..."
|
||||||
|
* };
|
||||||
|
* const content = resolvePrompt("executor-welcome", overrides);
|
||||||
|
* // Returns custom welcome if set, otherwise PROMPT_KEY_CATALOG["executor-welcome"].defaultContent
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function resolvePrompt(
|
||||||
|
key: PromptKey,
|
||||||
|
overrides?: PromptOverrideMap,
|
||||||
|
): string {
|
||||||
|
// Check for a valid override
|
||||||
|
if (overrides && key in overrides) {
|
||||||
|
const override = overrides[key];
|
||||||
|
// Non-empty string is a valid override
|
||||||
|
if (override !== undefined && override !== "") {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default
|
||||||
|
const metadata = PROMPT_KEY_CATALOG[key];
|
||||||
|
if (metadata) {
|
||||||
|
return metadata.defaultContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key not found — return empty string (graceful degradation)
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve all prompt overrides for a given role.
|
||||||
|
*
|
||||||
|
* Returns a map of prompt key → effective content (override or default)
|
||||||
|
* for all keys applicable to the specified role.
|
||||||
|
*
|
||||||
|
* @param role - The agent role to get prompts for
|
||||||
|
* @param overrides - The project-level overrides map (from settings)
|
||||||
|
* @returns Record mapping prompt keys to their effective content
|
||||||
|
*/
|
||||||
|
export function resolveRolePrompts(
|
||||||
|
role: AgentCapability,
|
||||||
|
overrides?: PromptOverrideMap,
|
||||||
|
): Record<PromptKey, string> {
|
||||||
|
const result: Partial<Record<PromptKey, string>> = {};
|
||||||
|
|
||||||
|
for (const meta of getPromptKeysForRole(role)) {
|
||||||
|
result[meta.key] = resolvePrompt(meta.key, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as Record<PromptKey, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any overrides are set for a given role.
|
||||||
|
*
|
||||||
|
* @param role - The agent role to check
|
||||||
|
* @param overrides - The project-level overrides map (from settings)
|
||||||
|
* @returns True if at least one override is set for the role
|
||||||
|
*/
|
||||||
|
export function hasRoleOverrides(
|
||||||
|
role: AgentCapability,
|
||||||
|
overrides?: PromptOverrideMap,
|
||||||
|
): boolean {
|
||||||
|
if (!overrides) return false;
|
||||||
|
|
||||||
|
const roleKeys = getPromptKeysForRole(role);
|
||||||
|
return roleKeys.some((meta) => {
|
||||||
|
const override = overrides[meta.key];
|
||||||
|
return override !== undefined && override !== "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all overridden keys (keys with non-empty override values).
|
||||||
|
*
|
||||||
|
* @param overrides - The project-level overrides map
|
||||||
|
* @returns Array of keys that have overrides set
|
||||||
|
*/
|
||||||
|
export function getOverriddenKeys(overrides?: PromptOverrideMap): PromptKey[] {
|
||||||
|
if (!overrides) return [];
|
||||||
|
|
||||||
|
return (Object.keys(overrides) as PromptKey[]).filter(
|
||||||
|
(key) => overrides[key] !== undefined && overrides[key] !== "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear specific override keys by setting them to undefined.
|
||||||
|
* Used by TaskStore.updateSettings() to implement null-as-delete semantics.
|
||||||
|
*
|
||||||
|
* @param overrides - Current overrides map
|
||||||
|
* @param keysToClear - Keys to clear (set to undefined)
|
||||||
|
* @returns New overrides map with specified keys cleared
|
||||||
|
*/
|
||||||
|
export function clearOverrides(
|
||||||
|
overrides: PromptOverrideMap | undefined,
|
||||||
|
keysToClear: PromptKey[],
|
||||||
|
): PromptOverrideMap | undefined {
|
||||||
|
if (!overrides && keysToClear.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: PromptOverrideMap = { ...overrides };
|
||||||
|
|
||||||
|
for (const key of keysToClear) {
|
||||||
|
delete result[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return undefined if map becomes empty (runtime uses defaults)
|
||||||
|
if (Object.keys(result).length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a value is a valid PromptKey.
|
||||||
|
*
|
||||||
|
* @param value - Value to check
|
||||||
|
* @returns True if the value is a valid PromptKey
|
||||||
|
*/
|
||||||
|
export function isValidPromptKey(value: unknown): value is PromptKey {
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
return value in PROMPT_KEY_CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that an object is a valid PromptOverrideMap.
|
||||||
|
*
|
||||||
|
* @param value - Value to check
|
||||||
|
* @returns True if the value is a valid PromptOverrideMap
|
||||||
|
*/
|
||||||
|
export function isValidPromptOverrideMap(value: unknown): value is PromptOverrideMap {
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
if (!isValidPromptKey(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Values must be strings or undefined
|
||||||
|
if (val !== undefined && typeof val !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to ensure a value is a valid PromptOverrideMap.
|
||||||
|
* Throws if the value is not a valid PromptOverrideMap.
|
||||||
|
*
|
||||||
|
* @param value - Value to validate
|
||||||
|
* @throws Error if the value is not a valid PromptOverrideMap
|
||||||
|
*/
|
||||||
|
export function assertValidPromptOverrideMap(value: unknown): asserts value is PromptOverrideMap {
|
||||||
|
if (!isValidPromptOverrideMap(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid prompt override map: expected Record<PromptKey, string | undefined>, got ${typeof value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,6 +388,105 @@ describe("settings-export", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("promptOverrides export/import", () => {
|
||||||
|
it("should export promptOverrides when set", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Custom welcome" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await exportSettings(store, { scope: "project" });
|
||||||
|
|
||||||
|
expect(result.project?.promptOverrides).toEqual({ "executor-welcome": "Custom welcome" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not export promptOverrides when not set", async () => {
|
||||||
|
const result = await exportSettings(store, { scope: "project" });
|
||||||
|
|
||||||
|
expect(result.project?.promptOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should import promptOverrides in merge mode", async () => {
|
||||||
|
const importData: SettingsExportData = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
project: {
|
||||||
|
promptOverrides: { "executor-welcome": "Imported welcome" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await importSettings(store, importData, { scope: "project", merge: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.projectCount).toBe(1);
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Imported welcome" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should merge promptOverrides with existing overrides", async () => {
|
||||||
|
// Set initial overrides
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Original" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const importData: SettingsExportData = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
project: {
|
||||||
|
promptOverrides: { "triage-welcome": "Imported triage" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await importSettings(store, importData, { scope: "project", merge: true });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({
|
||||||
|
"executor-welcome": "Original",
|
||||||
|
"triage-welcome": "Imported triage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear promptOverrides when importing null", async () => {
|
||||||
|
// Set initial overrides
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Original", "triage-welcome": "Triage" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const importData: SettingsExportData = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
project: {
|
||||||
|
promptOverrides: null as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await importSettings(store, importData, { scope: "project", merge: true });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear specific promptOverride key when importing null value", async () => {
|
||||||
|
// Set initial overrides
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Original", "triage-welcome": "Triage" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const importData: SettingsExportData = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
project: {
|
||||||
|
promptOverrides: { "executor-welcome": null as unknown as string },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await importSettings(store, importData, { scope: "project", merge: true });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({ "triage-welcome": "Triage" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("readExportFile", () => {
|
describe("readExportFile", () => {
|
||||||
it("should read and parse valid export file", async () => {
|
it("should read and parse valid export file", async () => {
|
||||||
const filePath = join(env.tempDir, "test-export.json");
|
const filePath = join(env.tempDir, "test-export.json");
|
||||||
|
|||||||
@@ -872,6 +872,164 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Prompt Overrides Tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("promptOverrides settings", () => {
|
||||||
|
it("can set a single prompt override", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Custom executor welcome message" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Custom executor welcome message" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can set multiple prompt overrides", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: {
|
||||||
|
"executor-welcome": "Custom welcome",
|
||||||
|
"triage-welcome": "Custom triage welcome",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({
|
||||||
|
"executor-welcome": "Custom welcome",
|
||||||
|
"triage-welcome": "Custom triage welcome",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promptOverrides is undefined by default", async () => {
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can merge new overrides with existing overrides", async () => {
|
||||||
|
// Set initial overrides
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Initial welcome" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add more overrides
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "triage-welcome": "Custom triage" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({
|
||||||
|
"executor-welcome": "Initial welcome",
|
||||||
|
"triage-welcome": "Custom triage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can update an existing override", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Original" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Updated" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Updated" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can clear a specific override with null value", async () => {
|
||||||
|
// Set initial overrides
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: {
|
||||||
|
"executor-welcome": "Welcome",
|
||||||
|
"triage-welcome": "Triage",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear only executor-welcome
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": null as unknown as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toEqual({ "triage-welcome": "Triage" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears entire promptOverrides when all keys are cleared", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Welcome" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": null as unknown as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can clear entire promptOverrides with null", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Welcome", "triage-welcome": "Triage" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: null as unknown as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists empty string overrides as cleared (not stored)", async () => {
|
||||||
|
// Setting an empty string should be treated as "clear" and not persist
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.promptOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles promptOverrides in getSettingsByScope", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Scoped welcome" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { project } = await store.getSettingsByScope();
|
||||||
|
expect(project.promptOverrides).toEqual({ "executor-welcome": "Scoped welcome" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other settings when updating promptOverrides", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
maxConcurrent: 5,
|
||||||
|
autoMerge: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Welcome" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.maxConcurrent).toBe(5);
|
||||||
|
expect(settings.autoMerge).toBe(false);
|
||||||
|
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Welcome" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves promptOverrides when updating other settings", async () => {
|
||||||
|
await store.updateSettings({
|
||||||
|
promptOverrides: { "executor-welcome": "Welcome", "triage-welcome": "Triage" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateSettings({ maxConcurrent: 7 });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.maxConcurrent).toBe(7);
|
||||||
|
expect(settings.promptOverrides).toEqual({
|
||||||
|
"executor-welcome": "Welcome",
|
||||||
|
"triage-welcome": "Triage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Concurrent stress test ───────────────────────────────────────
|
// ── Concurrent stress test ───────────────────────────────────────
|
||||||
|
|
||||||
describe("concurrent stress", () => {
|
describe("concurrent stress", () => {
|
||||||
|
|||||||
@@ -650,6 +650,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// Handle null values as "delete this key from settings"
|
// Handle null values as "delete this key from settings"
|
||||||
// This allows the frontend to explicitly clear a setting by sending null
|
// This allows the frontend to explicitly clear a setting by sending null
|
||||||
// (since JSON.stringify drops undefined keys, we use null as a sentinel)
|
// (since JSON.stringify drops undefined keys, we use null as a sentinel)
|
||||||
|
|
||||||
|
// Handle special null-as-delete semantics for promptOverrides
|
||||||
|
const incomingPromptOverrides = (projectPatch as Record<string, unknown>)["promptOverrides"];
|
||||||
|
if (incomingPromptOverrides === null) {
|
||||||
|
// promptOverrides: null → clear the entire promptOverrides object
|
||||||
|
delete (config.settings as unknown as Record<string, unknown>)["promptOverrides"];
|
||||||
|
delete (projectPatch as Record<string, unknown>)["promptOverrides"];
|
||||||
|
} else if (
|
||||||
|
incomingPromptOverrides !== undefined &&
|
||||||
|
typeof incomingPromptOverrides === "object" &&
|
||||||
|
incomingPromptOverrides !== null
|
||||||
|
) {
|
||||||
|
// promptOverrides: { key: value } → merge with existing, treating null values as delete
|
||||||
|
const incomingMap = incomingPromptOverrides as Record<string, unknown>;
|
||||||
|
const existingMap = ((config.settings as unknown as Record<string, unknown>)["promptOverrides"] as Record<string, string>) ?? {};
|
||||||
|
const mergedMap: Record<string, string> = { ...existingMap };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(incomingMap)) {
|
||||||
|
if (value === null) {
|
||||||
|
// null → delete this specific key
|
||||||
|
delete mergedMap[key];
|
||||||
|
} else if (typeof value === "string" && value !== "") {
|
||||||
|
// non-empty string → set this key
|
||||||
|
// Empty strings are treated as "clear" and not stored
|
||||||
|
mergedMap[key] = value;
|
||||||
|
}
|
||||||
|
// Empty strings are silently ignored (treated as "clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If merged map is empty, remove the entire promptOverrides
|
||||||
|
if (Object.keys(mergedMap).length === 0) {
|
||||||
|
delete (config.settings as unknown as Record<string, unknown>)["promptOverrides"];
|
||||||
|
delete (projectPatch as Record<string, unknown>)["promptOverrides"];
|
||||||
|
} else {
|
||||||
|
(config.settings as unknown as Record<string, unknown>)["promptOverrides"] = mergedMap;
|
||||||
|
(projectPatch as Record<string, unknown>)["promptOverrides"] = mergedMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null values for other top-level keys (non-promptOverrides)
|
||||||
for (const key of Object.keys(projectPatch)) {
|
for (const key of Object.keys(projectPatch)) {
|
||||||
if ((projectPatch as Record<string, unknown>)[key] === null) {
|
if ((projectPatch as Record<string, unknown>)[key] === null) {
|
||||||
delete (config.settings as unknown as Record<string, unknown>)[key];
|
delete (config.settings as unknown as Record<string, unknown>)[key];
|
||||||
|
|||||||
@@ -1105,6 +1105,20 @@ export interface ProjectSettings {
|
|||||||
* When set, allows per-project customization of system prompts
|
* When set, allows per-project customization of system prompts
|
||||||
* for different agent roles (executor, triage, reviewer, merger). */
|
* for different agent roles (executor, triage, reviewer, merger). */
|
||||||
agentPrompts?: AgentPromptsConfig;
|
agentPrompts?: AgentPromptsConfig;
|
||||||
|
/** Prompt segment overrides for fine-grained customization of agent prompts.
|
||||||
|
* Each key maps to a customizable prompt segment (e.g., "executor-welcome",
|
||||||
|
* "triage-context"). When a key is present with a non-empty value, that
|
||||||
|
* override replaces the default prompt segment. Missing or empty values
|
||||||
|
* fall back to the default prompt content.
|
||||||
|
*
|
||||||
|
* This is separate from `agentPrompts` which controls full role templates.
|
||||||
|
* `promptOverrides` allows surgical customization of specific prompt segments
|
||||||
|
* without replacing entire role prompts.
|
||||||
|
*
|
||||||
|
* Supported keys: "executor-welcome", "executor-guardrails", "executor-spawning",
|
||||||
|
* "executor-completion", "triage-welcome", "triage-context", "reviewer-verdict",
|
||||||
|
* "merger-conflicts". */
|
||||||
|
promptOverrides?: Record<string, string>;
|
||||||
/** Enable/disable agent self-reflection workflows. Default: false. */
|
/** Enable/disable agent self-reflection workflows. Default: false. */
|
||||||
reflectionEnabled?: boolean;
|
reflectionEnabled?: boolean;
|
||||||
/** How often periodic reflections occur in milliseconds. Default: 3_600_000 (1 hour). */
|
/** How often periodic reflections occur in milliseconds. Default: 3_600_000 (1 hour). */
|
||||||
@@ -1217,6 +1231,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
|||||||
missionMaxTaskRetries: 3,
|
missionMaxTaskRetries: 3,
|
||||||
missionHealthCheckIntervalMs: 300_000,
|
missionHealthCheckIntervalMs: 300_000,
|
||||||
agentPrompts: undefined,
|
agentPrompts: undefined,
|
||||||
|
promptOverrides: undefined,
|
||||||
reflectionEnabled: false,
|
reflectionEnabled: false,
|
||||||
reflectionIntervalMs: 3_600_000,
|
reflectionIntervalMs: 3_600_000,
|
||||||
reflectionAfterTask: true,
|
reflectionAfterTask: true,
|
||||||
@@ -1321,6 +1336,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
|||||||
"missionMaxTaskRetries",
|
"missionMaxTaskRetries",
|
||||||
"missionHealthCheckIntervalMs",
|
"missionHealthCheckIntervalMs",
|
||||||
"agentPrompts",
|
"agentPrompts",
|
||||||
|
"promptOverrides",
|
||||||
"reflectionEnabled",
|
"reflectionEnabled",
|
||||||
"reflectionIntervalMs",
|
"reflectionIntervalMs",
|
||||||
"reflectionAfterTask",
|
"reflectionAfterTask",
|
||||||
|
|||||||
Reference in New Issue
Block a user