feat(coding-agent): add configurable title system prompt for sessions
- add discovery of `TITLE_SYSTEM.md` and pass it through interactive startup context - route custom title prompts to online and local tiny title generators via protocol - update session-title docs and changelog with override behavior - add tests for prompt discovery, forwarding, and fallback to bundled title prompts
This commit is contained in:
@@ -243,6 +243,20 @@ Native provider (`id: native`) reads native config from:
|
||||
- `Settings.init()` loads global `config.yml` + discovered project settings capability items.
|
||||
- Only capability items with `level === "project"` are merged into project layer.
|
||||
|
||||
### Session title prompt override
|
||||
|
||||
Create `TITLE_SYSTEM.md` in the same config locations as `SYSTEM.md` / `APPEND_SYSTEM.md`:
|
||||
|
||||
```text
|
||||
# ~/.omp/agent/TITLE_SYSTEM.md
|
||||
Generate a session name using lowercase `<type>:<primary-objective>`.
|
||||
```
|
||||
|
||||
- Missing `TITLE_SYSTEM.md` keeps the bundled title prompts.
|
||||
- Discovery uses the same project-then-user config directory pattern as `SYSTEM.md`: project `.omp/TITLE_SYSTEM.md` first, then user `~/.omp/agent/TITLE_SYSTEM.md` and the other supported config bases.
|
||||
- The override replaces only the automatic session-title generation system prompt; normal `SYSTEM.md` / `APPEND_SYSTEM.md` prompt customization is unaffected.
|
||||
- The online path still forces the `set_title` tool call. The local tiny-title path keeps the `<title>...</title>` prefill/stop wrapper and uses this file as its system turn.
|
||||
|
||||
## Skills subsystem
|
||||
|
||||
- `extensibility/skills.ts` loads via `loadCapability(skillCapability.id, { cwd })`.
|
||||
|
||||
@@ -124,6 +124,18 @@ The dynamic project/environment footer that remains after `SYSTEM.md` is only bl
|
||||
|
||||
There is currently no supported CLI mode for "replace the stable default instructions but keep the generated skills/rules/tool guidance." If you need automatic skills loading, keep the default block and add your customization via `APPEND_SYSTEM.md`. If you fully replace with `SYSTEM.md`, you must hard-code any skill names/instructions you want the model to know about, and those will not track discovery automatically.
|
||||
|
||||
### "Customize automatic session titles"
|
||||
|
||||
`SYSTEM.md` and `APPEND_SYSTEM.md` do not affect the model call that names a new session. Create the title-specific prompt file instead:
|
||||
|
||||
```text
|
||||
# ~/.omp/agent/TITLE_SYSTEM.md
|
||||
Generate a session name using lowercase `<type>:<primary-objective>`.
|
||||
If the message carries no concrete task, output exactly `none`.
|
||||
```
|
||||
|
||||
`TITLE_SYSTEM.md` is discovered with the same project-then-user config-directory pattern as `SYSTEM.md` / `APPEND_SYSTEM.md`. When absent, OMP uses the bundled `title-system.md` / `tiny-title-system.md` prompts. When present, the online title path still forces the `set_title` tool call, and the local tiny-model path keeps the `<title>...</title>` wrapper while using this file as the system turn.
|
||||
|
||||
### "Replace everything, including project context" — SDK-only
|
||||
|
||||
The normal CLI file/flag path intentionally preserves `defaultPrompt.slice(1)`. Code using `CreateAgentSessionOptions.systemPrompt` directly can return a full replacement array and omit the project footer, but that is not what `.omp/SYSTEM.md`, `~/.omp/agent/SYSTEM.md`, or `--system-prompt` do.
|
||||
@@ -163,6 +175,7 @@ Net effect for CLI users: put `SYSTEM.md` / `APPEND_SYSTEM.md` directly under `<
|
||||
| Add an instruction on top of the full default prompt | `APPEND_SYSTEM.md` or `--append-system-prompt` |
|
||||
| Replace the stable default instructions but keep project/environment context | `SYSTEM.md` or `--system-prompt` |
|
||||
| Preserve generated skills/rules/tool guidance while customizing | `APPEND_SYSTEM.md`; `SYSTEM.md` replaces that generated block |
|
||||
| Customize automatic session titles | `TITLE_SYSTEM.md`; chat-turn `SYSTEM.md` / `APPEND_SYSTEM.md` do not affect title generation |
|
||||
| Use `{{cwd}}` / `{{date}}` / other internals in my file | Not supported. Files are inserted verbatim. |
|
||||
| Inherit specific sections from `system-prompt.md` | Not supported; use append, or copy what you need into `SYSTEM.md`. |
|
||||
| Override at a per-repo level | Project `.omp/SYSTEM.md` under the cwd you launch `omp` from |
|
||||
|
||||
@@ -133,6 +133,10 @@
|
||||
- Fixed Windows stdio MCP servers launched through PATH shims such as `codegraph.cmd` so bare commands like `codegraph` resolve via `PATHEXT` before spawn ([#2174](https://github.com/can1357/oh-my-pi/issues/2174)).
|
||||
- Fixed compiled-binary extensions failing to load `@oh-my-pi/pi-*` packages when `bun --compile` quietly dropped one of the extra entrypoints (observed on macOS arm64 release builds): the legacy-pi compat shim's package-root override branch returned the bunfs path without checking the target was present, so the rewrite emitted a `file://` URL to a missing module and the #1216 fallback (scoped to the throwing `getResolvedSpecifier` path) never ran. Override targets are now validated against the on-disk filesystem at module init, missing entries are dropped, and resolution falls through to canonical lookup so Bun resolves the import from the extension's own `node_modules` ([#2168](https://github.com/can1357/oh-my-pi/issues/2168)).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `TITLE_SYSTEM.md` discovery so users can override the automatic session-title generation prompt for online and local tiny title models without patching installed prompt files.
|
||||
|
||||
## [15.10.8] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -370,6 +370,7 @@ async function runInteractiveMode(
|
||||
eventBus?: EventBus,
|
||||
initialMessage?: string,
|
||||
initialImages?: ImageContent[],
|
||||
titleSystemPrompt?: string,
|
||||
): Promise<void> {
|
||||
const mode = new InteractiveMode(
|
||||
session,
|
||||
@@ -379,6 +380,7 @@ async function runInteractiveMode(
|
||||
lspServers,
|
||||
mcpManager,
|
||||
eventBus,
|
||||
titleSystemPrompt,
|
||||
);
|
||||
|
||||
// Cold-launch gate: the full setup wizard (every scene + the overlay and
|
||||
@@ -718,13 +720,26 @@ function discoverAppendSystemPromptFile(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Discover TITLE_SYSTEM.md file for automatic session-title prompt overrides */
|
||||
export function discoverTitleSystemPromptFile(cwd?: string): string | undefined {
|
||||
const projectPath = findConfigFile("TITLE_SYSTEM.md", { user: false, cwd });
|
||||
if (projectPath) {
|
||||
return projectPath;
|
||||
}
|
||||
const globalPath = findConfigFile("TITLE_SYSTEM.md", { user: true, cwd });
|
||||
if (globalPath) {
|
||||
return globalPath;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function buildSessionOptions(
|
||||
parsed: Args,
|
||||
scopedModels: ScopedModel[],
|
||||
sessionManager: SessionManager | undefined,
|
||||
modelRegistry: ModelRegistry,
|
||||
activeSettings: Settings,
|
||||
): Promise<{ options: CreateAgentSessionOptions }> {
|
||||
): Promise<{ options: CreateAgentSessionOptions; titleSystemPrompt?: string }> {
|
||||
const options: CreateAgentSessionOptions = {
|
||||
cwd: parsed.cwd ?? getProjectDir(),
|
||||
autoApprove: parsed.autoApprove ?? false,
|
||||
@@ -735,6 +750,8 @@ async function buildSessionOptions(
|
||||
const resolvedSystemPrompt = await resolvePromptInput(systemPromptSource, "system prompt");
|
||||
const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
|
||||
const resolvedAppendPrompt = await resolvePromptInput(appendPromptSource, "append system prompt");
|
||||
const titleSystemPromptSource = discoverTitleSystemPromptFile();
|
||||
const titleSystemPrompt = await resolvePromptInput(titleSystemPromptSource, "title system prompt");
|
||||
|
||||
if (sessionManager) {
|
||||
options.sessionManager = sessionManager;
|
||||
@@ -880,7 +897,7 @@ async function buildSessionOptions(
|
||||
options.additionalExtensionPaths = [];
|
||||
}
|
||||
|
||||
return { options };
|
||||
return { options, titleSystemPrompt };
|
||||
}
|
||||
|
||||
interface RunRootCommandDependencies {
|
||||
@@ -1133,7 +1150,7 @@ export async function runRootCommand(
|
||||
clearPluginRootsCache: clearPluginRootsAndCaches,
|
||||
});
|
||||
|
||||
const { options: sessionOptions } = await logger.time(
|
||||
const { options: sessionOptions, titleSystemPrompt } = await logger.time(
|
||||
"buildSessionOptions",
|
||||
buildSessionOptions,
|
||||
parsedArgs,
|
||||
@@ -1338,6 +1355,7 @@ export async function runRootCommand(
|
||||
eventBus,
|
||||
initialMessage,
|
||||
initialImages,
|
||||
titleSystemPrompt,
|
||||
);
|
||||
} else {
|
||||
// Branch-only single-shot runner: keep print-mode code out of normal interactive startup.
|
||||
|
||||
@@ -467,6 +467,7 @@ export class InputController {
|
||||
this.ctx.session.sessionId,
|
||||
this.ctx.session.model,
|
||||
provider => this.ctx.session.agent.metadataForProvider(provider),
|
||||
this.ctx.titleSystemPrompt,
|
||||
)
|
||||
.then(async title => {
|
||||
// Re-check: a concurrent attempt for an earlier message may have
|
||||
|
||||
@@ -260,6 +260,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
keybindings: KeybindingsManager;
|
||||
agent: Agent;
|
||||
historyStorage?: HistoryStorage;
|
||||
titleSystemPrompt?: string;
|
||||
|
||||
ui: TUI;
|
||||
chatContainer: TranscriptContainer;
|
||||
@@ -382,6 +383,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
lspServers: LspStartupServerInfo[] | undefined = undefined,
|
||||
mcpManager?: import("../mcp").MCPManager,
|
||||
eventBus?: EventBus,
|
||||
titleSystemPrompt?: string,
|
||||
) {
|
||||
this.session = session;
|
||||
this.sessionManager = session.sessionManager;
|
||||
@@ -394,6 +396,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.lspServers = lspServers;
|
||||
this.mcpManager = mcpManager;
|
||||
this.#eventBus = eventBus;
|
||||
this.titleSystemPrompt = titleSystemPrompt;
|
||||
if (eventBus) {
|
||||
this.#eventBusUnsubscribers.push(
|
||||
eventBus.on(LSP_STARTUP_EVENT_CHANNEL, data => {
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface InteractiveModeContext {
|
||||
historyStorage?: HistoryStorage;
|
||||
mcpManager?: MCPManager;
|
||||
lspServers?: LspStartupServerInfo[];
|
||||
titleSystemPrompt?: string;
|
||||
|
||||
// State
|
||||
isInitialized: boolean;
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface TinyTitleDownloadOptions {
|
||||
onProgress?: (event: TinyTitleProgressEvent) => void;
|
||||
}
|
||||
|
||||
export interface TinyTitleGenerateOptions {
|
||||
signal?: AbortSignal;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
// Cold-starting the worker subprocess from a compiled binary (decompress + module
|
||||
// graph load) is slow on contended CI runners — the macos-15-intel release smoke
|
||||
// blew past 5s while arm64/linux/win passed. The probe only needs to prove the
|
||||
@@ -46,6 +51,14 @@ export interface TinyTitleDownloadOptions {
|
||||
// generous bound removes the flake without weakening the check.
|
||||
const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function normalizeTinyTitleGenerateOptions(
|
||||
options: AbortSignal | TinyTitleGenerateOptions | undefined,
|
||||
): TinyTitleGenerateOptions {
|
||||
if (!options) return {};
|
||||
if ("aborted" in options && "addEventListener" in options) return { signal: options };
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden subcommand on the main CLI that boots the tiny-model worker in the
|
||||
* spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
|
||||
@@ -295,9 +308,16 @@ export class TinyTitleClient {
|
||||
return () => this.#progressListeners.delete(listener);
|
||||
}
|
||||
|
||||
async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null> {
|
||||
async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null>;
|
||||
async generate(modelKey: string, message: string, options?: TinyTitleGenerateOptions): Promise<string | null>;
|
||||
async generate(
|
||||
modelKey: string,
|
||||
message: string,
|
||||
optionsOrSignal?: AbortSignal | TinyTitleGenerateOptions,
|
||||
): Promise<string | null> {
|
||||
const options = normalizeTinyTitleGenerateOptions(optionsOrSignal);
|
||||
if (!isTinyTitleLocalModelKey(modelKey)) return null;
|
||||
if (signal?.aborted) return null;
|
||||
if (options.signal?.aborted) return null;
|
||||
|
||||
try {
|
||||
const worker = this.#ensureWorker();
|
||||
@@ -310,12 +330,15 @@ export class TinyTitleClient {
|
||||
this.#pending.delete(id);
|
||||
pending.resolve(null);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
worker.send({ type: "generate", id, modelKey, message });
|
||||
const request: TinyTitleWorkerInbound = options.systemPrompt
|
||||
? { type: "generate", id, modelKey, message, systemPrompt: options.systemPrompt }
|
||||
: { type: "generate", id, modelKey, message };
|
||||
worker.send(request);
|
||||
return await promise;
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
this.#pending.delete(id);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface TinyTitleProgressEvent {
|
||||
|
||||
export type TinyTitleWorkerInbound =
|
||||
| { type: "ping"; id: string }
|
||||
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
|
||||
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string; systemPrompt?: string }
|
||||
| { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
|
||||
| { type: "download"; id: string; modelKey: TinyLocalModelKey };
|
||||
|
||||
|
||||
@@ -436,9 +436,10 @@ async function loadPipeline(
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function buildPrompt(generator: TextGenerationPipeline, message: string): string {
|
||||
function buildPrompt(generator: TextGenerationPipeline, message: string, systemPrompt?: string): string {
|
||||
const selectedSystemPrompt = systemPrompt?.trim() || TINY_TITLE_SYSTEM_PROMPT;
|
||||
const chat = [
|
||||
{ role: "system", content: TINY_TITLE_SYSTEM_PROMPT },
|
||||
{ role: "system", content: selectedSystemPrompt },
|
||||
{ role: "user", content: formatTitleUserMessage(message) },
|
||||
];
|
||||
const chatTemplateOptions = {
|
||||
@@ -464,9 +465,10 @@ async function generateTitle(
|
||||
requestId: string,
|
||||
modelKey: TinyTitleLocalModelKey,
|
||||
message: string,
|
||||
systemPrompt?: string,
|
||||
): Promise<string | null> {
|
||||
const generator = await loadPipeline(modelKey, transport, requestId);
|
||||
const promptText = buildPrompt(generator, message);
|
||||
const promptText = buildPrompt(generator, message, systemPrompt);
|
||||
const transformers = await loadTransformers(transport, requestId, modelKey);
|
||||
const output = (await generator(promptText, {
|
||||
max_new_tokens: TITLE_MAX_NEW_TOKENS,
|
||||
@@ -548,7 +550,7 @@ async function handleQueuedRequest(
|
||||
transport.send({ type: "completion", id: request.id, text });
|
||||
return;
|
||||
}
|
||||
const title = await generateTitle(transport, request.id, request.modelKey, request.message);
|
||||
const title = await generateTitle(transport, request.id, request.modelKey, request.message, request.systemPrompt);
|
||||
transport.send({ type: "title", id: request.id, title });
|
||||
} catch (error) {
|
||||
transport.send({ type: "error", id: request.id, error: errorText(error) });
|
||||
|
||||
@@ -33,7 +33,7 @@ const setTitleTool: Tool = {
|
||||
title: {
|
||||
type: "string",
|
||||
description:
|
||||
'A concise, sentence-case 3-7 word title for the session (capitalize only the first word and proper nouns), or exactly "none" when the message carries no concrete task yet (greeting, small talk, vague).',
|
||||
'The generated session title, or exactly "none" when the message carries no concrete task yet.',
|
||||
},
|
||||
},
|
||||
required: ["title"],
|
||||
@@ -137,6 +137,7 @@ export async function raceFirstNonNull<T>(
|
||||
* to produce request metadata (e.g. user_id for session attribution). Using a
|
||||
* resolver instead of a pre-evaluated value ensures the metadata's account_uuid
|
||||
* reflects the credential actually selected for this request.
|
||||
* @param customSystemPrompt Optional title-specific system prompt override
|
||||
*/
|
||||
export async function generateSessionTitle(
|
||||
firstMessage: string,
|
||||
@@ -145,6 +146,7 @@ export async function generateSessionTitle(
|
||||
sessionId?: string,
|
||||
currentModel?: Model<Api>,
|
||||
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
||||
customSystemPrompt?: string,
|
||||
): Promise<string | null> {
|
||||
// Defer titling for greetings / acknowledgements / empty input. The default
|
||||
// tiny title model can't reliably decline trivial input, so this happens
|
||||
@@ -155,13 +157,26 @@ export async function generateSessionTitle(
|
||||
return null;
|
||||
}
|
||||
|
||||
const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
|
||||
const tinyModel = settings.get("providers.tinyModel");
|
||||
if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
|
||||
return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
|
||||
return generateTitleOnline(
|
||||
firstMessage,
|
||||
registry,
|
||||
settings,
|
||||
sessionId,
|
||||
currentModel,
|
||||
metadataResolver,
|
||||
undefined,
|
||||
titleSystemPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
const onlineAbortController = new AbortController();
|
||||
const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
|
||||
const localTitlePromise = titleSystemPrompt
|
||||
? tinyTitleClient.generate(tinyModel, firstMessage, { systemPrompt: titleSystemPrompt })
|
||||
: tinyTitleClient.generate(tinyModel, firstMessage);
|
||||
const localTitle = localTitlePromise.then(
|
||||
title => title || null,
|
||||
err => {
|
||||
logger.warn("title-generator: local model error", {
|
||||
@@ -181,6 +196,7 @@ export async function generateSessionTitle(
|
||||
currentModel,
|
||||
metadataResolver,
|
||||
onlineAbortController.signal,
|
||||
titleSystemPrompt,
|
||||
);
|
||||
|
||||
return raceFirstNonNull(localTitle, startOnline, TITLE_LOCAL_FALLBACK_DELAY_MS, () => {
|
||||
@@ -196,6 +212,7 @@ export async function generateTitleOnline(
|
||||
currentModel?: Model<Api>,
|
||||
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
|
||||
signal?: AbortSignal,
|
||||
customSystemPrompt?: string,
|
||||
): Promise<string | null> {
|
||||
const model = getTitleModel(registry, settings, currentModel);
|
||||
if (!model) {
|
||||
@@ -203,6 +220,8 @@ export async function generateTitleOnline(
|
||||
return null;
|
||||
}
|
||||
|
||||
const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
|
||||
const systemPrompt = titleSystemPrompt ?? TITLE_SYSTEM_PROMPT;
|
||||
const userMessage = formatTitleUserMessage(firstMessage);
|
||||
const modelName = `${model.provider}/${model.id}`;
|
||||
const modelContext = {
|
||||
@@ -234,7 +253,7 @@ export async function generateTitleOnline(
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{
|
||||
systemPrompt: [TITLE_SYSTEM_PROMPT],
|
||||
systemPrompt: [systemPrompt],
|
||||
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
||||
tools: [setTitleTool],
|
||||
},
|
||||
|
||||
@@ -33,6 +33,35 @@ class FakeTinyWorker {
|
||||
}
|
||||
}
|
||||
|
||||
describe("tiny title client prompt options", () => {
|
||||
it("forwards a custom system prompt on local title requests", async () => {
|
||||
let sent: TinyTitleWorkerInbound | undefined;
|
||||
const worker = new FakeTinyWorker((message, worker) => {
|
||||
sent = message;
|
||||
if (message.type === "generate") {
|
||||
worker.emit({ type: "title", id: message.id, title: "custom title" });
|
||||
}
|
||||
});
|
||||
const client = new TinyTitleClient(() => worker);
|
||||
|
||||
try {
|
||||
const title = await client.generate("lfm2-350m", "Investigate routing", {
|
||||
systemPrompt: "Custom title prompt",
|
||||
});
|
||||
|
||||
expect(title).toBe("custom title");
|
||||
expect(sent).toMatchObject({
|
||||
type: "generate",
|
||||
modelKey: "lfm2-350m",
|
||||
message: "Investigate routing",
|
||||
systemPrompt: "Custom title prompt",
|
||||
});
|
||||
} finally {
|
||||
await client.terminate();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #1940 — local model failures release the worker process", () => {
|
||||
it("recycles the tiny-model worker after model execution returns an error", async () => {
|
||||
const first = new FakeTinyWorker((message, worker) => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import { submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main";
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { discoverTitleSystemPromptFile, submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main";
|
||||
import type { SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanupDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function createInput(overrides: Partial<SubmittedUserInput> = {}): SubmittedUserInput {
|
||||
return {
|
||||
text: "hello",
|
||||
@@ -12,6 +21,19 @@ function createInput(overrides: Partial<SubmittedUserInput> = {}): SubmittedUser
|
||||
};
|
||||
}
|
||||
|
||||
describe("discoverTitleSystemPromptFile", () => {
|
||||
it("discovers TITLE_SYSTEM.md from the project omp config directory", async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-title-system-"));
|
||||
cleanupDirs.push(projectDir);
|
||||
const configDir = path.join(projectDir, ".omp");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const promptPath = path.join(configDir, "TITLE_SYSTEM.md");
|
||||
await fs.writeFile(promptPath, "custom title prompt");
|
||||
|
||||
expect(discoverTitleSystemPromptFile(projectDir)).toBe(promptPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitInteractiveInput", () => {
|
||||
it("routes already-started synthetic continue submissions to a hidden developer prompt", async () => {
|
||||
const mode = {
|
||||
|
||||
@@ -243,6 +243,27 @@ describe("tiny title generator routing", () => {
|
||||
expect(online).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the resolved TITLE_SYSTEM.md prompt to the local client", async () => {
|
||||
const model = getModelOrThrow("claude-sonnet-4-5");
|
||||
const customPrompt = "Generate lowercase colon-delimited session names.";
|
||||
const local = vi.spyOn(tinyTitleClient, "generate").mockResolvedValue("Local Title");
|
||||
const online = mockOnlineTitle("Online Title");
|
||||
|
||||
const title = await generateSessionTitle(
|
||||
"Investigate routing",
|
||||
createRegistry(model),
|
||||
createSettings(model, "lfm2-350m"),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customPrompt,
|
||||
);
|
||||
|
||||
expect(title).toBe("Local Title");
|
||||
expect(local).toHaveBeenCalledWith("lfm2-350m", "Investigate routing", { systemPrompt: customPrompt });
|
||||
expect(online).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts online fallback immediately when local returns null", async () => {
|
||||
const model = getModelOrThrow("claude-sonnet-4-5");
|
||||
vi.spyOn(tinyTitleClient, "generate").mockResolvedValue(null);
|
||||
|
||||
@@ -71,6 +71,49 @@ describe("title generator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the bundled default prompt when no title prompt file is resolved", async () => {
|
||||
const model = getModelOrThrow("claude-sonnet-4-5");
|
||||
const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "toolCall", id: "call-title", name: "set_title", arguments: { title: "Default Prompt" } }],
|
||||
} as never);
|
||||
|
||||
await generateSessionTitle("Investigate the resolver", createRegistry(model), createSettings(model));
|
||||
|
||||
const request = completeSimpleMock.mock.calls[0]?.[1] as { systemPrompt?: string[] } | undefined;
|
||||
expect(request?.systemPrompt).toHaveLength(1);
|
||||
expect(request?.systemPrompt?.[0]).toContain("Generate a concise, sentence-case title");
|
||||
});
|
||||
|
||||
it("uses the resolved TITLE_SYSTEM.md prompt for online title generation", async () => {
|
||||
const model = getModelOrThrow("claude-sonnet-4-5");
|
||||
const customPrompt = "Generate lowercase colon-delimited session names.";
|
||||
const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "toolCall", id: "call-title", name: "set_title", arguments: { title: "fix:resolver" } }],
|
||||
} as never);
|
||||
|
||||
await generateSessionTitle(
|
||||
"Investigate the resolver",
|
||||
createRegistry(model),
|
||||
createSettings(model),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customPrompt,
|
||||
);
|
||||
|
||||
const request = completeSimpleMock.mock.calls[0]?.[1] as
|
||||
| { systemPrompt?: string[]; tools?: Array<{ name?: string }> }
|
||||
| undefined;
|
||||
const options = completeSimpleMock.mock.calls[0]?.[2] as
|
||||
| { toolChoice?: { type?: string; name?: string } }
|
||||
| undefined;
|
||||
expect(request?.systemPrompt).toEqual([customPrompt]);
|
||||
expect(request?.tools?.[0]?.name).toBe("set_title");
|
||||
expect(options?.toolChoice).toEqual({ type: "tool", name: "set_title" });
|
||||
});
|
||||
|
||||
it("falls back to text content when no set_title tool call is returned", async () => {
|
||||
const model = getModelOrThrow("claude-sonnet-4-5");
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user