From 1524f7fb01e331bf58d6e357ff4eda6d6b2d49cc Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 17:11:11 +0200 Subject: [PATCH] feat(coding-agent): added WATCHDOG.md discovery and advisor startup behavior updates - Added discovery of local, user, and ancestor `WATCHDOG.md` files via `discoverWatchdogFiles`. - Appended discovered watchdog prompts to advisor system prompts during session setup. - Added protocol startup defaults that force `advisor.enabled` and `advisor.subagents` false. - Handled `maintainContext` failures and drained pending updates before token estimation. --- packages/coding-agent/CHANGELOG.md | 10 +- .../src/advisor/__tests__/advisor.test.ts | 77 +++++++ packages/coding-agent/src/advisor/index.ts | 1 + packages/coding-agent/src/advisor/runtime.ts | 19 +- packages/coding-agent/src/advisor/watchdog.ts | 83 +++++++ packages/coding-agent/src/main.ts | 4 + .../src/prompts/advisor/system.md | 4 +- packages/coding-agent/src/sdk.ts | 12 +- .../coding-agent/src/session/agent-session.ts | 132 +++++------ .../test/acp-lazy-startup.test.ts | 76 +++++++ .../coding-agent/test/advisor-toggle.test.ts | 11 + .../test/advisor-watchdog.test.ts | 210 ++++++++++++++++++ 12 files changed, 550 insertions(+), 89 deletions(-) create mode 100644 packages/coding-agent/src/advisor/watchdog.ts create mode 100644 packages/coding-agent/test/advisor-watchdog.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 883bb8ba9..fa01e8cb4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Renamed the SDK tool format type and resolver from `ToolCallFormat`/`resolveToolCallSyntax` to `DialectFormat`/`resolveDialect`, and the agent option from `toolCallSyntax` to `dialect`. @@ -8,6 +9,7 @@ ### Added +- Added automatic discovery of WATCHDOG.md files from user and project locations so advisor guidance from local watchdog instructions is appended to its system prompt - Added `/advisor on`, `/advisor off`, `/advisor status`, and `/advisor dump [raw]` slash-command subcommands to manage the advisor at runtime - Added `advisor.enabled` and `advisor.subagents` settings to enable the advisor and extend it to spawned task/eval subagents - Added advisor status badge (`++` in success color) to the status line when an advisor is active @@ -17,14 +19,18 @@ ### Changed +- Changed advisor prompting guidance to emit at most one `advise` per update and to prefer silence when the agent is on track - Changed `/dump` default output to compact markdown format; use `/dump raw` for the legacy uncompact format - Changed `/dump` and `/advisor dump` to default to compact transcript output and accept an optional `raw` flag for the legacy uncompact format - Session dump output now renders message history using the model's native dialect turn envelope instead of markdown role headings +- Changed RPC, RPC-UI, and ACP hosts to default the advisor settings off instead of inheriting a user's interactive-session advisor preference. ### Fixed -- Fixed advisor message card notes getting truncated to two lines when the card is collapsed. -- Fixed advisor context handling to automatically maintain its token budget by promoting the advisor model or compacting/restarting advisor context when needed, preventing advice from degrading on long sessions +- Fixed advisor context batching to still send pending review updates when context maintenance fails instead of dropping the batch +- Fixed explicit advisor enablement to clear protocol-default overrides so enabling the advisor applies immediately +- Fixed advisor message card notes getting truncated to two lines when the card is collapsed. +- Fixed advisor context handling to maintain its token budget by promoting or compacting/restarting advisor context while preserving advisor reasoning-off settings. - Fixed `startup.quiet` leaving MCP and LSP startup status events visible during launch ([#2639](https://github.com/can1357/oh-my-pi/issues/2639)). - Registered the `Advisor` group in the `model` settings tab so advisor settings render correctly in the settings panel. diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index 61ade18c0..a6f2cc2f3 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -183,6 +183,83 @@ describe("advisor", () => { expect(promptInputs[1]).toContain("second"); }); + it("budgets only the batch sent after async context maintenance", async () => { + const promptInputs: string[] = []; + const { promise: firstMaintainStarted, resolve: startFirstMaintain } = Promise.withResolvers(); + const { promise: finishFirstMaintain, resolve: releaseFirstMaintain } = Promise.withResolvers(); + const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers(); + const { promise: secondPromptStarted, resolve: startSecondPrompt } = Promise.withResolvers(); + const { promise: finishFirstPrompt, resolve: releaseFirstPrompt } = Promise.withResolvers(); + let maintainCalls = 0; + let promptCalls = 0; + const agent: AdvisorAgent = { + prompt: async input => { + promptInputs.push(input); + promptCalls++; + if (promptCalls === 1) { + startFirstPrompt(); + await finishFirstPrompt; + } else if (promptCalls === 2) { + startSecondPrompt(); + } + }, + abort: () => {}, + reset: () => {}, + state: { messages: [] }, + }; + const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + maintainContext: async () => { + maintainCalls++; + if (maintainCalls === 1) { + startFirstMaintain(); + return await finishFirstMaintain; + } + return false; + }, + }; + const runtime = new AdvisorRuntime(agent, host); + + runtime.onTurnEnd(); + await firstMaintainStarted; + messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage); + runtime.onTurnEnd(); + + releaseFirstMaintain(false); + await firstPromptStarted; + expect(promptInputs).toHaveLength(1); + expect(promptInputs[0]).toContain("first"); + expect(promptInputs[0]).not.toContain("second"); + + releaseFirstPrompt(); + await secondPromptStarted; + expect(promptInputs).toHaveLength(2); + expect(promptInputs[1]).toContain("second"); + }); + + it("sends the batch when context maintenance fails", async () => { + const promptInputs: string[] = []; + const agent = makeAgent(promptInputs); + const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + maintainContext: async () => { + throw new Error("maintenance failed"); + }, + }; + const runtime = new AdvisorRuntime(agent, host); + + runtime.onTurnEnd(); + await Promise.resolve(); + await Promise.resolve(); + + expect(promptInputs).toHaveLength(1); + expect(promptInputs[0]).toContain("first"); + }); + it("excludes advisor custom messages from the rendered delta", () => { const promptInputs: string[] = []; const agent = makeAgent(promptInputs); diff --git a/packages/coding-agent/src/advisor/index.ts b/packages/coding-agent/src/advisor/index.ts index 8931d883f..30dd105e5 100644 --- a/packages/coding-agent/src/advisor/index.ts +++ b/packages/coding-agent/src/advisor/index.ts @@ -1,2 +1,3 @@ export * from "./advise-tool"; export * from "./runtime"; +export * from "./watchdog"; diff --git a/packages/coding-agent/src/advisor/runtime.ts b/packages/coding-agent/src/advisor/runtime.ts index fc4f0530f..34eda7931 100644 --- a/packages/coding-agent/src/advisor/runtime.ts +++ b/packages/coding-agent/src/advisor/runtime.ts @@ -104,22 +104,29 @@ export class AdvisorRuntime { this.#busy = true; try { while (!this.#disposed && this.#pending.length) { - const candidateBatch = this.#pending.join("\n\n---\n\n"); + const pendingBatch = this.#pending.splice(0).join("\n\n---\n\n"); const incomingTokens = estimateTokens({ role: "user", - content: candidateBatch, + content: pendingBatch, timestamp: Date.now(), }); - let batch: string | null; - if (this.host.maintainContext && (await this.host.maintainContext(incomingTokens))) { + let shouldReprime = false; + if (this.host.maintainContext) { + try { + shouldReprime = await this.host.maintainContext(incomingTokens); + } catch (err) { + logger.debug("advisor context maintenance failed", { err: String(err) }); + } + } + + let batch: string | null = pendingBatch; + if (shouldReprime) { // Promotion could not fit the advisor's context — re-prime: drop the // accumulated review history and replay the current (primary-bounded) // transcript so the next turn resumes from a fresh, in-window context. this.reset(); batch = this.#renderDelta(); - } else { - batch = this.#pending.splice(0).join("\n\n---\n\n"); } if (this.#disposed || batch === null) continue; try { diff --git a/packages/coding-agent/src/advisor/watchdog.ts b/packages/coding-agent/src/advisor/watchdog.ts new file mode 100644 index 000000000..d3595ff81 --- /dev/null +++ b/packages/coding-agent/src/advisor/watchdog.ts @@ -0,0 +1,83 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { expandAtImports } from "../discovery/at-imports"; +import { repo } from "../utils/git"; + +/** + * Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir. + * Returns formatted watchdog file blocks ready to be appended to the advisor system prompt. + */ +export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise { + const home = os.homedir(); + const resolvedAgentDir = agentDir ?? getAgentDir(); + const userPath = resolvedAgentDir ? path.resolve(resolvedAgentDir, "WATCHDOG.md") : null; + let repoRoot: string | null = null; + try { + repoRoot = await repo.root(cwd); + } catch (err) { + logger.debug("Failed to resolve git root for watchdog discovery", { err: String(err) }); + } + + const candidates = new Set(); + + // 1. User level: ~/.omp/WATCHDOG.md (or active profile agent dir) + if (resolvedAgentDir) { + candidates.add(path.resolve(resolvedAgentDir, "WATCHDOG.md")); + } + + // 2. Project levels (both standalone and native config .omp/): walk up from cwd to repoRoot / home + let current = cwd; + while (true) { + candidates.add(path.resolve(current, ".omp", "WATCHDOG.md")); + candidates.add(path.resolve(current, "WATCHDOG.md")); + + if (current === (repoRoot ?? home)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + const items: Array<{ path: string; content: string; level: "user" | "project"; depth: number }> = []; + + for (const candidate of candidates) { + try { + const content = await Bun.file(candidate).text(); + const expanded = await expandAtImports(content, candidate); + const parent = path.dirname(candidate); + const baseName = parent.split(path.sep).pop() ?? ""; + + const isUser = userPath !== null && candidate === userPath; + const ownerDir = baseName === ".omp" ? path.dirname(parent) : parent; + const ownerBaseName = ownerDir.split(path.sep).pop() ?? ""; + + if (isUser || !ownerBaseName.startsWith(".") || baseName === ".omp") { + const relative = path.relative(cwd, ownerDir); + const depth = relative === "" ? 0 : relative.split(path.sep).filter(Boolean).length; + items.push({ + path: candidate, + content: expanded, + level: isUser ? "user" : "project", + depth, + }); + } + } catch (err) { + if (!isEnoent(err)) { + logger.warn("Failed to read WATCHDOG.md candidate", { path: candidate, error: String(err) }); + } + } + } + + // Sort files so that user level comes first, then project level sorted by depth (descending). + // This means user-level rules are first, then project-level rules from ancestor directories down to the leaf directory (depth 0 is last/most prominent). + items.sort((a, b) => { + if (a.level !== b.level) { + return a.level === "user" ? -1 : 1; + } + return b.depth - a.depth; + }); + + return items.map(item => { + return `Especially pay attention to:\n\n${item.content}\n`; + }); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4308ffc0e..524e4e2af 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -140,6 +140,10 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [ // memory should opt in explicitly through their own settings layer. "memory.backend", "memories.enabled", + // Advisor is interactive-session assistance. Protocol hosts opt in explicitly + // instead of inheriting a user's globally-enabled local preference. + "advisor.enabled", + "advisor.subagents", ]; const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [ diff --git a/packages/coding-agent/src/prompts/advisor/system.md b/packages/coding-agent/src/prompts/advisor/system.md index 4dfbc6095..4c7dc7934 100644 --- a/packages/coding-agent/src/prompts/advisor/system.md +++ b/packages/coding-agent/src/prompts/advisor/system.md @@ -1,5 +1,5 @@ -RFC 2119 applies: MUST, SHOULD, AVOID, NEVER (= NEVER). You are a pair programmer with independent perspective — your only output is the `advise` tool. +RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` are aliases for `MUST NOT` and `SHOULD NOT`. You can explore the workspace; budget is 2–3 tool calls per advise (exception: critical bugs warrant deeper verification before raising a blocker). @@ -14,7 +14,7 @@ Keep exploration lean — 2–3 calls per advise unless you've spotted a critica -One `advise` per update. Address the agent directly. Offer alternatives, not lectures. Never restate what they know; never explain how to use the advisor. +At most one `advise` per update. Prefer silence when the agent is on track. Address the agent directly. Offer alternatives, not lectures. Never restate what they know; never explain how to use the advisor. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 36c754ce9..6ea5b63ef 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -35,7 +35,7 @@ import { prompt, Snowflake, } from "@oh-my-pi/pi-utils"; -import { ADVISOR_READONLY_TOOL_NAMES } from "./advisor"; +import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor"; import { type AsyncJob, AsyncJobManager } from "./async"; import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller"; import { loadCapability } from "./capability"; @@ -1142,6 +1142,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ? Promise.resolve(options.contextFiles) : logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir); contextFilesPromise.catch(() => {}); + const watchdogFilesPromise = logger.time("discoverWatchdogFiles", () => discoverWatchdogFiles(cwd, agentDir)); + watchdogFilesPromise.catch(() => {}); const promptTemplatesPromise = options.promptTemplates ? Promise.resolve(options.promptTemplates) : logger.time("discoverPromptTemplates", discoverPromptTemplates, cwd, agentDir); @@ -1371,9 +1373,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } return result; }; - const [contextFiles, resolvedWorkspaceTree] = await Promise.all([ + const [contextFiles, resolvedWorkspaceTree, watchdogFiles] = await Promise.all([ contextFilesPromise, raceWithDeadline("buildWorkspaceTree", workspaceTreePromise), + watchdogFilesPromise, ]); let agent: Agent; @@ -2567,7 +2570,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} .filter((tool): tool is Tool => tool != null) .map(wrapToolWithMetaNotice); + let advisorWatchdogPrompt: string | undefined; + if (watchdogFiles && watchdogFiles.length > 0) { + advisorWatchdogPrompt = watchdogFiles.join("\n\n"); + } session = new AgentSession({ + advisorWatchdogPrompt, agent, thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel, sessionManager, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c01d0bac1..0cc5ed718 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -480,6 +480,8 @@ export interface AgentSessionConfig { * caches. Undefined when the advisor is disabled. */ advisorReadOnlyTools?: AgentTool[]; + /** Preloaded watchdog prompt content for the advisor. */ + advisorWatchdogPrompt?: string; } /** Options for AgentSession.prompt() */ @@ -993,6 +995,7 @@ export class AgentSession { /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */ #advisorAgent?: Agent; #advisorReadOnlyTools?: AgentTool[]; + #advisorWatchdogPrompt?: string; #advisorYieldQueueUnsubscribe?: () => void; #goalTurnCounter = 0; #planReferenceSent = false; @@ -1285,6 +1288,7 @@ export class AgentSession { this.#skillsSettings = config.skillsSettings; this.#modelRegistry = config.modelRegistry; this.#advisorReadOnlyTools = config.advisorReadOnlyTools; + this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt; this.#validateRetryFallbackChains(); this.#toolRegistry = config.toolRegistry ?? new Map(); this.#requestedToolNames = config.requestedToolNames; @@ -1484,9 +1488,13 @@ export class AgentSession { const appendOnlyContext = new AppendOnlyContextManager(); const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium; + const systemPrompt = [advisorSystemPrompt]; + if (this.#advisorWatchdogPrompt) { + systemPrompt.push(this.#advisorWatchdogPrompt); + } const advisorAgent = new Agent({ initialState: { - systemPrompt: [advisorSystemPrompt], + systemPrompt, model: advisorSel.model, thinkingLevel: toReasoningEffort(advisorThinkingLevel), tools: [adviseTool, ...advisorReadOnlyTools], @@ -1660,90 +1668,59 @@ export class AgentSession { return true; } - let action: "context-full" | "snapcompact" = - compactionSettings.strategy === "snapcompact" && advisorModel.input.includes("image") - ? "snapcompact" - : "context-full"; + const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning + ? ThinkingLevel.Off + : advisor.state.thinkingLevel; - let summary: string; - let shortSummary: string | undefined; - let firstKeptEntryId: string; - let tokensBefore: number; + // Advisor state is in-memory-only, so snapcompact's frame archive has no + // stable SessionEntry preserveData slot to carry across future advisor + // maintenance runs. Use an LLM summary even when the primary session is + // configured for snapcompact. + const availableModels = this.#modelRegistry.getAvailable(); + const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels); + if (candidates.length === 0) { + // No compaction candidates, fallback to re-prime + return true; + } + + let compactResult: CompactionResult | undefined; + let lastError: unknown; + + for (const candidate of candidates) { + const apiKey = await this.#modelRegistry.getApiKey( + candidate, + this.sessionId ? `${this.sessionId}-advisor` : undefined, + ); + if (!apiKey) continue; - // Try snapcompact first if vision model - let snapcompactResult: snapcompact.CompactionResult | undefined; - if (action === "snapcompact") { try { - snapcompactResult = await snapcompact.compact(preparation, { - convertToLlm: messages => this.#convertToLlmForSideRequest(messages), - model: advisorModel, - maxFrames: snapcompact.providerFrameBudget(advisorModel.provider), - }); - const budget = contextWindow - effectiveReserveTokens(contextWindow, compactionSettings); - const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult); - if (projected > budget) { - action = "context-full"; - snapcompactResult = undefined; - } - } catch (err) { - logger.warn("Advisor snapcompact failed, falling back to LLM summary", { error: String(err) }); - action = "context-full"; - } - } - - if (snapcompactResult) { - summary = snapcompactResult.summary; - shortSummary = snapcompactResult.shortSummary; - firstKeptEntryId = snapcompactResult.firstKeptEntryId; - tokensBefore = snapcompactResult.tokensBefore; - } else { - // Run LLM-summary compaction - const availableModels = this.#modelRegistry.getAvailable(); - const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels); - if (candidates.length === 0) { - // No compaction candidates, fallback to re-prime - return true; - } - - let compactResult: CompactionResult | undefined; - let lastError: unknown; - - for (const candidate of candidates) { - const apiKey = await this.#modelRegistry.getApiKey( + compactResult = await compact( + preparation, candidate, - this.sessionId ? `${this.sessionId}-advisor` : undefined, + this.#modelRegistry.resolver(candidate, this.sessionId ? `${this.sessionId}-advisor` : undefined), + undefined, + undefined, + { + thinkingLevel: advisorCompactionThinkingLevel, + convertToLlm: messages => this.#convertToLlmForSideRequest(messages), + }, ); - if (!apiKey) continue; - - try { - compactResult = await compact( - preparation, - candidate, - this.#modelRegistry.resolver(candidate, this.sessionId ? `${this.sessionId}-advisor` : undefined), - undefined, - undefined, - { - thinkingLevel: toReasoningEffort(advisor.state.thinkingLevel), - convertToLlm: messages => this.#convertToLlmForSideRequest(messages), - }, - ); - break; - } catch (error) { - lastError = error; - } + break; + } catch (error) { + lastError = error; } - - if (!compactResult) { - logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) }); - return true; - } - - summary = compactResult.summary; - shortSummary = compactResult.shortSummary; - firstKeptEntryId = compactResult.firstKeptEntryId; - tokensBefore = compactResult.tokensBefore; } + if (!compactResult) { + logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) }); + return true; + } + + const summary = compactResult.summary; + const shortSummary = compactResult.shortSummary; + const firstKeptEntryId = compactResult.firstKeptEntryId; + const tokensBefore = compactResult.tokensBefore; + // Rebuild messages with the compaction summary const summaryMessage = { ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary), @@ -11214,6 +11191,7 @@ export class AgentSession { */ setAdvisorEnabled(enabled: boolean): boolean { if (enabled) { + this.settings.clearOverride("advisor.enabled"); this.settings.set("advisor.enabled", true); return this.#buildAdvisorRuntime(true); } diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index d8368d4ae..679a7fd97 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -241,6 +241,82 @@ describe("ACP lazy startup", () => { bashAutoBackgroundThresholdMs: 1234, }); }); + + it("default-disables advisor for protocol hosts", async () => { + const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); + + type ObservedAdvisorSettings = { + enabled: boolean; + subagents: boolean; + }; + + const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise => { + using tempDir = TempDir.createSync("@omp-protocol-advisor-settings-"); + const cwd = tempDir.path(); + const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); + const settings = Settings.isolated({ + "advisor.enabled": true, + "advisor.subagents": true, + }); + let observed: ObservedAdvisorSettings | undefined; + const stopMessage = "stop test protocol mode"; + + try { + await runRootCommand( + { + mode, + messages: [], + fileArgs: [], + unknownFlags: new Map(), + unrecognizedFlags: [], + noSkills: true, + noRules: true, + noTools: true, + noLsp: true, + noExtensions: true, + sessionDir: cwd, + }, + [], + { + discoverAuthStorage: async () => authStorage, + settings, + createAgentSession: async () => { + observed = { + enabled: settings.get("advisor.enabled"), + subagents: settings.get("advisor.subagents"), + }; + throw new Error(stopMessage); + }, + runAcpMode: async () => { + observed = { + enabled: settings.get("advisor.enabled"), + subagents: settings.get("advisor.subagents"), + }; + throw new Error(stopMessage); + }, + }, + ); + } catch (error) { + if (!(error instanceof Error) || error.message !== stopMessage) { + throw error; + } + } finally { + authStorage.close(); + } + + if (!observed) { + throw new Error("Expected protocol mode to start"); + } + return observed; + }; + + for (const mode of ["rpc", "rpc-ui", "acp"] as const) { + await expect(runProtocolStartup(mode)).resolves.toEqual({ + enabled: false, + subagents: false, + }); + } + }); it("answers initialize before creating the first AgentSession", async () => { const clientToAgent = new TransformStream(); const agentToClient = new TransformStream(); diff --git a/packages/coding-agent/test/advisor-toggle.test.ts b/packages/coding-agent/test/advisor-toggle.test.ts index d7b35e5c5..05597dca8 100644 --- a/packages/coding-agent/test/advisor-toggle.test.ts +++ b/packages/coding-agent/test/advisor-toggle.test.ts @@ -80,6 +80,17 @@ describe("AgentSession advisor toggle", () => { expect(session.formatAdvisorStatus()).toContain("Advisor is enabled (anthropic/claude-sonnet-4-5)"); }); + it("explicit enable clears protocol default-off override", () => { + session.settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5"); + session.settings.override("advisor.enabled", false); + + const active = session.setAdvisorEnabled(true); + + expect(active).toBe(true); + expect(session.isAdvisorActive()).toBe(true); + expect(session.settings.get("advisor.enabled")).toBe(true); + }); + it("toggle disables the advisor and runtime", () => { session.settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5"); session.toggleAdvisorEnabled(); diff --git a/packages/coding-agent/test/advisor-watchdog.test.ts b/packages/coding-agent/test/advisor-watchdog.test.ts new file mode 100644 index 000000000..6977023f1 --- /dev/null +++ b/packages/coding-agent/test/advisor-watchdog.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { Snowflake } from "@oh-my-pi/pi-utils"; + +describe("advisor watchdog prompt discovery", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("discovers and appends WATCHDOG.md to the advisor prompt", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-advisor-watchdog-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwd = path.join(tempDir, "project-root"); + fs.mkdirSync(cwd, { recursive: true }); + + // Write a WATCHDOG.md file + const watchdogContent = "Watchdog rule: Watch out for cheating on edits."; + fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8"); + + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey("openai", "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + + const sessionManager = SessionManager.create(cwd, path.join(tempDir, "sessions")); + const { session } = await createAgentSession({ + cwd, + agentDir: tempDir, + sessionManager, + authStorage, + modelRegistry, + settings: (() => { + const s = Settings.isolated({ + "async.enabled": false, + "advisor.enabled": true, + }); + s.setModelRole("advisor", "openai/gpt-4o-mini"); + return s; + })(), + model: getBundledModel("openai", "gpt-4o-mini"), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + + try { + expect(session.isAdvisorActive()).toBe(true); + const dump = session.formatAdvisorHistoryAsText(); + expect(dump).not.toBeNull(); + expect(dump).toContain("Especially pay attention to:"); + expect(dump).toContain(""); + expect(dump).toContain(watchdogContent); + expect(dump).toContain(""); + } finally { + await session.dispose(); + } + }); + + it("resolves nested folders and sorts by depth", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-advisor-watchdog-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const parentCwd = path.join(tempDir, "project-root"); + const childCwd = path.join(parentCwd, "subfolder"); + fs.mkdirSync(childCwd, { recursive: true }); + + // Write two WATCHDOG.md files + const parentWatchdogContent = "Parent watchdog rule."; + const childWatchdogContent = "Child watchdog rule."; + fs.writeFileSync(path.join(parentCwd, "WATCHDOG.md"), parentWatchdogContent, "utf8"); + fs.writeFileSync(path.join(childCwd, "WATCHDOG.md"), childWatchdogContent, "utf8"); + + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey("openai", "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + + const sessionManager = SessionManager.create(childCwd, path.join(tempDir, "sessions")); + const { session } = await createAgentSession({ + cwd: childCwd, + agentDir: tempDir, + sessionManager, + authStorage, + modelRegistry, + settings: (() => { + const s = Settings.isolated({ + "async.enabled": false, + "advisor.enabled": true, + }); + s.setModelRole("advisor", "openai/gpt-4o-mini"); + return s; + })(), + model: getBundledModel("openai", "gpt-4o-mini"), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + + try { + expect(session.isAdvisorActive()).toBe(true); + const dump = session.formatAdvisorHistoryAsText(); + expect(dump).not.toBeNull(); + expect(dump).toContain("Especially pay attention to:"); + expect(dump).toContain(""); + expect(dump).toContain(""); + expect(dump).toContain(parentWatchdogContent); + expect(dump).toContain(childWatchdogContent); + // Check ordering: parent is farther (depth 1), child is closer (depth 0). + // So parent watchdog should appear first, followed by child watchdog. + const parentIndex = dump!.indexOf(parentWatchdogContent); + const childIndex = dump!.indexOf(childWatchdogContent); + expect(parentIndex).toBeGreaterThan(-1); + expect(childIndex).toBeGreaterThan(-1); + expect(parentIndex).toBeLessThan(childIndex); + } finally { + await session.dispose(); + } + }); + + it("discovers user-level and native project-level watchdog files", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-advisor-watchdog-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwd = path.join(tempDir, "project-root"); + const ompDir = path.join(cwd, ".omp"); + const userAgentDir = path.join(tempDir, "user-agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(ompDir, { recursive: true }); + fs.mkdirSync(userAgentDir, { recursive: true }); + + const userWatchdogContent = "User-level watchdog rule."; + const nativeWatchdogContent = "Native project watchdog rule."; + const standaloneWatchdogContent = "Standalone project watchdog rule."; + + fs.writeFileSync(path.join(userAgentDir, "WATCHDOG.md"), userWatchdogContent, "utf8"); + fs.writeFileSync(path.join(ompDir, "WATCHDOG.md"), nativeWatchdogContent, "utf8"); + fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), standaloneWatchdogContent, "utf8"); + + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey("openai", "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + + const sessionManager = SessionManager.create(cwd, path.join(tempDir, "sessions")); + const { session } = await createAgentSession({ + cwd, + agentDir: userAgentDir, + sessionManager, + authStorage, + modelRegistry, + settings: (() => { + const s = Settings.isolated({ + "async.enabled": false, + "advisor.enabled": true, + }); + s.setModelRole("advisor", "openai/gpt-4o-mini"); + return s; + })(), + model: getBundledModel("openai", "gpt-4o-mini"), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + + try { + expect(session.isAdvisorActive()).toBe(true); + const dump = session.formatAdvisorHistoryAsText(); + expect(dump).not.toBeNull(); + expect(dump).toContain(userWatchdogContent); + expect(dump).toContain(nativeWatchdogContent); + expect(dump).toContain(standaloneWatchdogContent); + + // Check ordering: user-level should appear first, then native project level (.omp/WATCHDOG.md has depth 0), + // then standalone project level (cwd/WATCHDOG.md has depth 0). + // Between native and standalone, they both have depth 0, so their relative order doesn't strictly matter + // as long as user-level comes before both of them. + const userIndex = dump!.indexOf(userWatchdogContent); + const nativeIndex = dump!.indexOf(nativeWatchdogContent); + const standaloneIndex = dump!.indexOf(standaloneWatchdogContent); + + expect(userIndex).toBeGreaterThan(-1); + expect(nativeIndex).toBeGreaterThan(-1); + expect(standaloneIndex).toBeGreaterThan(-1); + + expect(userIndex).toBeLessThan(nativeIndex); + expect(userIndex).toBeLessThan(standaloneIndex); + } finally { + await session.dispose(); + } + }); +});