diff --git a/docs/config-usage.md b/docs/config-usage.md index f2d778214..1cbf861cd 100644 --- a/docs/config-usage.md +++ b/docs/config-usage.md @@ -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 `:`. +``` + +- 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 `...` prefill/stop wrapper and uses this file as its system turn. + ## Skills subsystem - `extensibility/skills.ts` loads via `loadCapability(skillCapability.id, { cwd })`. diff --git a/docs/system-prompt-customization.md b/docs/system-prompt-customization.md index c81b06ec4..a70ffcd43 100644 --- a/docs/system-prompt-customization.md +++ b/docs/system-prompt-customization.md @@ -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 `:`. +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 `...` 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 | diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee70e90fe..057fb0975 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 3fe16d8b0..3de2fbbb8 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -370,6 +370,7 @@ async function runInteractiveMode( eventBus?: EventBus, initialMessage?: string, initialImages?: ImageContent[], + titleSystemPrompt?: string, ): Promise { 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. diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index b363d4db2..de55b97ad 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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 diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index b2dc31ba3..f27357cbf 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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 => { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index bec732f20..b904799bd 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -99,6 +99,7 @@ export interface InteractiveModeContext { historyStorage?: HistoryStorage; mcpManager?: MCPManager; lspServers?: LspStartupServerInfo[]; + titleSystemPrompt?: string; // State isInitialized: boolean; diff --git a/packages/coding-agent/src/tiny/title-client.ts b/packages/coding-agent/src/tiny/title-client.ts index 0ffe83185..c26d50c40 100644 --- a/packages/coding-agent/src/tiny/title-client.ts +++ b/packages/coding-agent/src/tiny/title-client.ts @@ -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 { + async generate(modelKey: string, message: string, signal?: AbortSignal): Promise; + async generate(modelKey: string, message: string, options?: TinyTitleGenerateOptions): Promise; + async generate( + modelKey: string, + message: string, + optionsOrSignal?: AbortSignal | TinyTitleGenerateOptions, + ): Promise { + 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) { diff --git a/packages/coding-agent/src/tiny/title-protocol.ts b/packages/coding-agent/src/tiny/title-protocol.ts index 4f1bd67ba..5bc86f525 100644 --- a/packages/coding-agent/src/tiny/title-protocol.ts +++ b/packages/coding-agent/src/tiny/title-protocol.ts @@ -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 }; diff --git a/packages/coding-agent/src/tiny/worker.ts b/packages/coding-agent/src/tiny/worker.ts index 416a9aede..633405006 100644 --- a/packages/coding-agent/src/tiny/worker.ts +++ b/packages/coding-agent/src/tiny/worker.ts @@ -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 { 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) }); diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index fc4e34428..5927c4677 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -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( * 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, metadataResolver?: (provider: string) => Record | undefined, + customSystemPrompt?: string, ): Promise { // 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, metadataResolver?: (provider: string) => Record | undefined, signal?: AbortSignal, + customSystemPrompt?: string, ): Promise { 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], }, diff --git a/packages/coding-agent/test/issue-1940-repro.test.ts b/packages/coding-agent/test/issue-1940-repro.test.ts index 9121f496d..ae7fbb5c3 100644 --- a/packages/coding-agent/test/issue-1940-repro.test.ts +++ b/packages/coding-agent/test/issue-1940-repro.test.ts @@ -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) => { diff --git a/packages/coding-agent/test/main-interactive-input.test.ts b/packages/coding-agent/test/main-interactive-input.test.ts index 835a97a13..b8e855869 100644 --- a/packages/coding-agent/test/main-interactive-input.test.ts +++ b/packages/coding-agent/test/main-interactive-input.test.ts @@ -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 { return { text: "hello", @@ -12,6 +21,19 @@ function createInput(overrides: Partial = {}): 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 = { diff --git a/packages/coding-agent/test/tiny-title-generator.test.ts b/packages/coding-agent/test/tiny-title-generator.test.ts index 3255f28c1..1994f30e9 100644 --- a/packages/coding-agent/test/tiny-title-generator.test.ts +++ b/packages/coding-agent/test/tiny-title-generator.test.ts @@ -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); diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index 0a3354da1..19a65bbff 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -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({