Merge origin/dev into feature/upgrade-minimax-m2.7 (resolve conflicts)
This commit is contained in:
@@ -44,6 +44,10 @@ export function mergeAgentConfig(
|
||||
const { prompt_append, ...rest } = migratedOverride
|
||||
const merged = deepMerge(base, rest as Partial<AgentConfig>)
|
||||
|
||||
if (merged.prompt && typeof merged.prompt === 'string' && merged.prompt.startsWith('file://')) {
|
||||
merged.prompt = resolvePromptAppend(merged.prompt, directory)
|
||||
}
|
||||
|
||||
if (prompt_append && merged.prompt) {
|
||||
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append, directory)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function maybeCreateAtlasConfig(input: {
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
const atlasResolution = applyModelResolution({
|
||||
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
|
||||
uiSelectedModel: orchestratorOverride?.model !== undefined ? undefined : uiSelectedModel,
|
||||
userModel: orchestratorOverride?.model,
|
||||
requirement: atlasRequirement,
|
||||
availableModels,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buildAgent, isFactory } from "../agent-builder"
|
||||
import { applyOverrides } from "./agent-overrides"
|
||||
import { applyEnvironmentContext } from "./environment-context"
|
||||
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function collectPendingBuiltinAgents(input: {
|
||||
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
|
||||
@@ -69,13 +70,19 @@ export function collectPendingBuiltinAgents(input: {
|
||||
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
|
||||
|
||||
let resolution = applyModelResolution({
|
||||
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
|
||||
uiSelectedModel: (isPrimaryAgent && override?.model === undefined) ? uiSelectedModel : undefined,
|
||||
userModel: override?.model,
|
||||
requirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolution && isFirstRunNoCache && !override?.model) {
|
||||
if (!resolution) {
|
||||
if (override?.model) {
|
||||
log("[agent-registration] User-configured model could not be resolved, falling back", {
|
||||
agent: agentName,
|
||||
configuredModel: override.model,
|
||||
})
|
||||
}
|
||||
resolution = getFirstFallbackModel(requirement)
|
||||
}
|
||||
if (!resolution) continue
|
||||
|
||||
@@ -52,7 +52,7 @@ export function maybeCreateSisyphusConfig(input: {
|
||||
if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) return undefined
|
||||
|
||||
let sisyphusResolution = applyModelResolution({
|
||||
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
|
||||
uiSelectedModel: sisyphusOverride?.model !== undefined ? undefined : uiSelectedModel,
|
||||
userModel: sisyphusOverride?.model,
|
||||
requirement: sisyphusRequirement,
|
||||
availableModels,
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("buildParallelDelegationSection", () => {
|
||||
|
||||
it("#given non-Claude model with deep category #when building #then returns aggressive delegation section", () => {
|
||||
//#given
|
||||
const model = "google/gemini-3-pro"
|
||||
const model = "google/gemini-3.1-pro"
|
||||
const categories = [deepCategory, otherCategory]
|
||||
|
||||
//#when
|
||||
@@ -237,7 +237,7 @@ describe("buildParallelDelegationSection", () => {
|
||||
describe("buildNonClaudePlannerSection", () => {
|
||||
it("#given non-Claude model #when building #then returns plan agent section", () => {
|
||||
//#given
|
||||
const model = "google/gemini-3-pro"
|
||||
const model = "google/gemini-3.1-pro"
|
||||
|
||||
//#when
|
||||
const result = buildNonClaudePlannerSection(model)
|
||||
@@ -272,4 +272,3 @@ describe("buildNonClaudePlannerSection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -162,6 +162,10 @@ Asking the user is the LAST resort after exhausting creative alternatives.
|
||||
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
|
||||
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
|
||||
|
||||
### Task Scope Clarification
|
||||
|
||||
You handle multi-step sub-tasks of a SINGLE GOAL. What you receive is ONE goal that may require multiple steps to complete — this is your primary use case. Only reject when given MULTIPLE INDEPENDENT goals in one request.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
${hardBlocks}
|
||||
|
||||
@@ -121,6 +121,10 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
|
||||
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
|
||||
|
||||
### Task Scope Clarification
|
||||
|
||||
You handle multi-step sub-tasks of a SINGLE GOAL. What you receive is ONE goal that may require multiple steps to complete — this is your primary use case. Only reject when given MULTIPLE INDEPENDENT goals in one request.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
${hardBlocks}
|
||||
|
||||
@@ -112,6 +112,10 @@ Asking the user is the LAST resort after exhausting creative alternatives.
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
|
||||
### Task Scope Clarification
|
||||
|
||||
You handle multi-step sub-tasks of a SINGLE GOAL. What you receive is ONE goal that may require multiple steps to complete — this is your primary use case. Only reject when given MULTIPLE INDEPENDENT goals in one request.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
${hardBlocks}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { getPrometheusPrompt } from "./system-prompt"
|
||||
|
||||
describe("getPrometheusPrompt", () => {
|
||||
describe("#given question tool is not disabled", () => {
|
||||
describe("#when generating prompt", () => {
|
||||
it("#then should include Question tool references", () => {
|
||||
const prompt = getPrometheusPrompt(undefined, [])
|
||||
|
||||
expect(prompt).toContain("Question({")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given question tool is disabled via disabled_tools", () => {
|
||||
describe("#when generating prompt", () => {
|
||||
it("#then should strip Question tool code examples", () => {
|
||||
const prompt = getPrometheusPrompt(undefined, ["question"])
|
||||
|
||||
expect(prompt).not.toContain("Question({")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when disabled_tools includes question among other tools", () => {
|
||||
it("#then should strip Question tool code examples", () => {
|
||||
const prompt = getPrometheusPrompt(undefined, ["todowrite", "question", "interactive_bash"])
|
||||
|
||||
expect(prompt).not.toContain("Question({")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no disabled_tools provided", () => {
|
||||
describe("#when generating prompt with undefined", () => {
|
||||
it("#then should include Question tool references", () => {
|
||||
const prompt = getPrometheusPrompt(undefined, undefined)
|
||||
|
||||
expect(prompt).toContain("Question({")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -52,16 +52,34 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
|
||||
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
|
||||
* Default (Claude, etc.) → Claude-optimized prompt (modular sections)
|
||||
*/
|
||||
export function getPrometheusPrompt(model?: string): string {
|
||||
export function getPrometheusPrompt(model?: string, disabledTools?: readonly string[]): string {
|
||||
const source = getPrometheusPromptSource(model)
|
||||
const isQuestionDisabled = disabledTools?.includes("question") ?? false
|
||||
|
||||
let prompt: string
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptPrometheusPrompt()
|
||||
prompt = getGptPrometheusPrompt()
|
||||
break
|
||||
case "gemini":
|
||||
return getGeminiPrometheusPrompt()
|
||||
prompt = getGeminiPrometheusPrompt()
|
||||
break
|
||||
case "default":
|
||||
default:
|
||||
return PROMETHEUS_SYSTEM_PROMPT
|
||||
prompt = PROMETHEUS_SYSTEM_PROMPT
|
||||
}
|
||||
|
||||
if (isQuestionDisabled) {
|
||||
prompt = stripQuestionToolReferences(prompt)
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes Question tool usage examples from prompt text when question tool is disabled.
|
||||
*/
|
||||
function stripQuestionToolReferences(prompt: string): string {
|
||||
// Remove Question({...}) code blocks (multi-line)
|
||||
return prompt.replace(/```typescript\n\s*Question\(\{[\s\S]*?\}\)\s*\n```/g, "")
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ Task NOT complete without:
|
||||
- ${verificationText}
|
||||
</Verification>
|
||||
|
||||
<Termination>
|
||||
STOP after first successful verification. Do NOT re-verify.
|
||||
Maximum status checks: 2. Then stop regardless.
|
||||
</Termination>
|
||||
|
||||
<Style>
|
||||
- Start immediately. No acknowledgments.
|
||||
- Match user's communication style.
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ export type AgentName = BuiltinAgentName;
|
||||
export type AgentOverrideConfig = Partial<AgentConfig> & {
|
||||
prompt_append?: string;
|
||||
variant?: string;
|
||||
fallback_models?: string | string[];
|
||||
fallback_models?: string | (string | import("../config/schema/fallback-models").FallbackModelObject)[];
|
||||
};
|
||||
|
||||
export type AgentOverrides = Partial<
|
||||
|
||||
+17
-1
@@ -3,6 +3,7 @@ import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
@@ -42,7 +43,7 @@ Examples:
|
||||
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
|
||||
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
|
||||
OpenAI Native openai/ models (GPT-5.4 for Oracle)
|
||||
Gemini Native google/ models (Gemini 3 Pro, Flash)
|
||||
Gemini Native google/ models (Gemini 3.1 Pro, Flash)
|
||||
Copilot github-copilot/ models (fallback)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
|
||||
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
|
||||
@@ -176,6 +177,21 @@ Examples:
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("refresh-model-capabilities")
|
||||
.description("Refresh the cached models.dev-based model capabilities snapshot")
|
||||
.option("-d, --directory <path>", "Working directory to read oh-my-opencode config from")
|
||||
.option("--source-url <url>", "Override the models.dev source URL")
|
||||
.option("--json", "Output refresh summary as JSON")
|
||||
.action(async (options) => {
|
||||
const exitCode = await refreshModelCapabilities({
|
||||
directory: options.directory,
|
||||
sourceUrl: options.sourceUrl,
|
||||
json: options.json ?? false,
|
||||
})
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show version information")
|
||||
|
||||
@@ -2,15 +2,15 @@ import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { OhMyOpenCodeConfigSchema } from "../../../config"
|
||||
import { detectConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
|
||||
import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import type { CheckResult, DoctorIssue } from "../types"
|
||||
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
|
||||
import { getModelResolutionInfoWithOverrides } from "./model-resolution"
|
||||
import type { OmoConfig } from "./model-resolution-types"
|
||||
|
||||
const USER_CONFIG_BASE = join(getOpenCodeConfigDir({ binary: "opencode" }), PACKAGE_NAME)
|
||||
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
|
||||
const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode")
|
||||
|
||||
interface ConfigValidationResult {
|
||||
exists: boolean
|
||||
@@ -21,10 +21,10 @@ interface ConfigValidationResult {
|
||||
}
|
||||
|
||||
function findConfigPath(): string | null {
|
||||
const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE)
|
||||
const projectConfig = detectPluginConfigFile(PROJECT_CONFIG_DIR)
|
||||
if (projectConfig.format !== "none") return projectConfig.path
|
||||
|
||||
const userConfig = detectConfigFile(USER_CONFIG_BASE)
|
||||
const userConfig = detectPluginConfigFile(USER_CONFIG_DIR)
|
||||
if (userConfig.format !== "none") return userConfig.path
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { detectConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
import { detectPluginConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
import type { OmoConfig } from "./model-resolution-types"
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const USER_CONFIG_BASE = join(
|
||||
getOpenCodeConfigPaths({ binary: "opencode", version: null }).configDir,
|
||||
PACKAGE_NAME
|
||||
)
|
||||
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
|
||||
const USER_CONFIG_DIR = getOpenCodeConfigPaths({ binary: "opencode", version: null }).configDir
|
||||
const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode")
|
||||
|
||||
export function loadOmoConfig(): OmoConfig | null {
|
||||
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
|
||||
const projectDetected = detectPluginConfigFile(PROJECT_CONFIG_DIR)
|
||||
if (projectDetected.format !== "none") {
|
||||
try {
|
||||
const content = readFileSync(projectDetected.path, "utf-8")
|
||||
@@ -21,7 +17,7 @@ export function loadOmoConfig(): OmoConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
const userDetected = detectConfigFile(USER_CONFIG_BASE)
|
||||
const userDetected = detectPluginConfigFile(USER_CONFIG_DIR)
|
||||
if (userDetected.format !== "none") {
|
||||
try {
|
||||
const content = readFileSync(userDetected.path, "utf-8")
|
||||
|
||||
@@ -4,6 +4,10 @@ import { getOpenCodeCacheDir } from "../../../shared"
|
||||
import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
|
||||
import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant"
|
||||
|
||||
function formatCapabilityResolutionLabel(mode: string | undefined): string {
|
||||
return mode ?? "unknown"
|
||||
}
|
||||
|
||||
export function buildModelResolutionDetails(options: {
|
||||
info: ModelResolutionInfo
|
||||
available: AvailableModelsInfo
|
||||
@@ -37,7 +41,7 @@ export function buildModelResolutionDetails(options: {
|
||||
agent.effectiveModel,
|
||||
getEffectiveVariant(agent.name, agent.requirement, options.config)
|
||||
)
|
||||
details.push(` ${marker} ${agent.name}: ${display}`)
|
||||
details.push(` ${marker} ${agent.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(agent.capabilityDiagnostics?.resolutionMode)}]`)
|
||||
}
|
||||
details.push("")
|
||||
details.push("Categories:")
|
||||
@@ -47,7 +51,7 @@ export function buildModelResolutionDetails(options: {
|
||||
category.effectiveModel,
|
||||
getCategoryEffectiveVariant(category.name, category.requirement, options.config)
|
||||
)
|
||||
details.push(` ${marker} ${category.name}: ${display}`)
|
||||
details.push(` ${marker} ${category.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(category.capabilityDiagnostics?.resolutionMode)}]`)
|
||||
}
|
||||
details.push("")
|
||||
details.push("● = user override, ○ = provider fallback")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ModelCapabilitiesDiagnostics } from "../../../shared/model-capabilities"
|
||||
import type { ModelRequirement } from "../../../shared/model-requirements"
|
||||
|
||||
export interface AgentResolutionInfo {
|
||||
@@ -7,6 +8,7 @@ export interface AgentResolutionInfo {
|
||||
userVariant?: string
|
||||
effectiveModel: string
|
||||
effectiveResolution: string
|
||||
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
|
||||
}
|
||||
|
||||
export interface CategoryResolutionInfo {
|
||||
@@ -16,6 +18,7 @@ export interface CategoryResolutionInfo {
|
||||
userVariant?: string
|
||||
effectiveModel: string
|
||||
effectiveResolution: string
|
||||
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
|
||||
}
|
||||
|
||||
export interface ModelResolutionInfo {
|
||||
|
||||
@@ -129,6 +129,19 @@ describe("model-resolution check", () => {
|
||||
expect(visual!.userOverride).toBe("google/gemini-3-flash-preview")
|
||||
expect(visual!.userVariant).toBe("high")
|
||||
})
|
||||
|
||||
it("attaches snapshot-backed capability diagnostics for built-in models", async () => {
|
||||
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({})
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.capabilityDiagnostics).toMatchObject({
|
||||
resolutionMode: "snapshot-backed",
|
||||
snapshot: { source: "bundled-snapshot" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkModelResolution", () => {
|
||||
@@ -162,6 +175,23 @@ describe("model-resolution check", () => {
|
||||
expect(result.details!.some((d) => d.includes("Categories:"))).toBe(true)
|
||||
// Should have legend
|
||||
expect(result.details!.some((d) => d.includes("user override"))).toBe(true)
|
||||
expect(result.details!.some((d) => d.includes("capabilities: snapshot-backed"))).toBe(true)
|
||||
})
|
||||
|
||||
it("collects warnings when configured models rely on compatibility fallback", async () => {
|
||||
const { collectCapabilityResolutionIssues, getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({
|
||||
agents: {
|
||||
oracle: { model: "custom/unknown-llm" },
|
||||
},
|
||||
})
|
||||
|
||||
const issues = collectCapabilityResolutionIssues(info)
|
||||
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0]?.title).toContain("compatibility fallback")
|
||||
expect(issues[0]?.description).toContain("oracle=custom/unknown-llm")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
|
||||
import { getModelCapabilities } from "../../../shared/model-capabilities"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { CheckResult, DoctorIssue } from "../types"
|
||||
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
|
||||
@@ -7,16 +8,36 @@ import { buildModelResolutionDetails } from "./model-resolution-details"
|
||||
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
|
||||
import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
|
||||
|
||||
export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
}))
|
||||
function parseProviderModel(value: string): { providerID: string; modelID: string } | null {
|
||||
const slashIndex = value.indexOf("/")
|
||||
if (slashIndex <= 0 || slashIndex === value.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => ({
|
||||
return {
|
||||
providerID: value.slice(0, slashIndex),
|
||||
modelID: value.slice(slashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function attachCapabilityDiagnostics<T extends AgentResolutionInfo | CategoryResolutionInfo>(entry: T): T {
|
||||
const parsed = parseProviderModel(entry.effectiveModel)
|
||||
if (!parsed) {
|
||||
return entry
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
capabilityDiagnostics: getModelCapabilities({
|
||||
providerID: parsed.providerID,
|
||||
modelID: parsed.modelID,
|
||||
}).diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) =>
|
||||
attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
@@ -24,6 +45,16 @@ export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
})
|
||||
)
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) =>
|
||||
attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
})
|
||||
)
|
||||
|
||||
return { agents, categories }
|
||||
}
|
||||
|
||||
@@ -31,34 +62,60 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => {
|
||||
const userOverride = config.agents?.[name]?.model
|
||||
const userVariant = config.agents?.[name]?.variant
|
||||
return {
|
||||
return attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => {
|
||||
const userOverride = config.categories?.[name]?.model
|
||||
const userVariant = config.categories?.[name]?.variant
|
||||
return {
|
||||
return attachCapabilityDiagnostics({
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return { agents, categories }
|
||||
}
|
||||
|
||||
export function collectCapabilityResolutionIssues(info: ModelResolutionInfo): DoctorIssue[] {
|
||||
const issues: DoctorIssue[] = []
|
||||
const allEntries = [...info.agents, ...info.categories]
|
||||
const fallbackEntries = allEntries.filter((entry) => {
|
||||
const mode = entry.capabilityDiagnostics?.resolutionMode
|
||||
return mode === "alias-backed" || mode === "heuristic-backed" || mode === "unknown"
|
||||
})
|
||||
|
||||
if (fallbackEntries.length === 0) {
|
||||
return issues
|
||||
}
|
||||
|
||||
const summary = fallbackEntries
|
||||
.map((entry) => `${entry.name}=${entry.effectiveModel} (${entry.capabilityDiagnostics?.resolutionMode ?? "unknown"})`)
|
||||
.join(", ")
|
||||
|
||||
issues.push({
|
||||
title: "Configured models rely on compatibility fallback",
|
||||
description: summary,
|
||||
severity: "warning",
|
||||
affects: fallbackEntries.map((entry) => entry.name),
|
||||
})
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export async function checkModels(): Promise<CheckResult> {
|
||||
const config = loadOmoConfig() ?? {}
|
||||
const info = getModelResolutionInfoWithOverrides(config)
|
||||
@@ -75,6 +132,8 @@ export async function checkModels(): Promise<CheckResult> {
|
||||
})
|
||||
}
|
||||
|
||||
issues.push(...collectCapabilityResolutionIssues(info))
|
||||
|
||||
const overrideCount =
|
||||
info.agents.filter((agent) => Boolean(agent.userOverride)).length +
|
||||
info.categories.filter((category) => Boolean(category.userOverride)).length
|
||||
|
||||
@@ -53,6 +53,14 @@ describe("install CLI - binary check behavior", () => {
|
||||
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(false)
|
||||
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null)
|
||||
|
||||
// given mock npm fetch
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "3.0.0" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const args: InstallArgs = {
|
||||
tui: false,
|
||||
claude: "yes",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||
|
||||
describe("refreshModelCapabilities", () => {
|
||||
it("uses config source_url when CLI override is absent", async () => {
|
||||
const loadConfig = mock(() => ({
|
||||
model_capabilities: {
|
||||
source_url: "https://mirror.example/api.json",
|
||||
},
|
||||
}))
|
||||
const refreshCache = mock(async () => ({
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
sourceUrl: "https://mirror.example/api.json",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
},
|
||||
}))
|
||||
let stdout = ""
|
||||
|
||||
const exitCode = await refreshModelCapabilities(
|
||||
{ directory: "/repo", json: false },
|
||||
{
|
||||
loadConfig,
|
||||
refreshCache,
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk
|
||||
return true
|
||||
},
|
||||
} as never,
|
||||
stderr: {
|
||||
write: () => true,
|
||||
} as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(loadConfig).toHaveBeenCalledWith("/repo", null)
|
||||
expect(refreshCache).toHaveBeenCalledWith({
|
||||
sourceUrl: "https://mirror.example/api.json",
|
||||
})
|
||||
expect(stdout).toContain("Refreshed model capabilities cache (1 models)")
|
||||
})
|
||||
|
||||
it("CLI sourceUrl overrides config and supports json output", async () => {
|
||||
const refreshCache = mock(async () => ({
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6" },
|
||||
},
|
||||
}))
|
||||
let stdout = ""
|
||||
|
||||
const exitCode = await refreshModelCapabilities(
|
||||
{
|
||||
directory: "/repo",
|
||||
json: true,
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
},
|
||||
{
|
||||
loadConfig: () => ({}),
|
||||
refreshCache,
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk
|
||||
return true
|
||||
},
|
||||
} as never,
|
||||
stderr: {
|
||||
write: () => true,
|
||||
} as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(refreshCache).toHaveBeenCalledWith({
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
generatedAt: "2026-03-25T00:00:00.000Z",
|
||||
modelCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("returns exit code 1 when refresh fails", async () => {
|
||||
let stderr = ""
|
||||
|
||||
const exitCode = await refreshModelCapabilities(
|
||||
{ directory: "/repo" },
|
||||
{
|
||||
loadConfig: () => ({}),
|
||||
refreshCache: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
stdout: {
|
||||
write: () => true,
|
||||
} as never,
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk
|
||||
return true
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr).toContain("Failed to refresh model capabilities cache")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { loadPluginConfig } from "../plugin-config"
|
||||
import { refreshModelCapabilitiesCache } from "../shared/model-capabilities-cache"
|
||||
|
||||
export type RefreshModelCapabilitiesOptions = {
|
||||
directory?: string
|
||||
json?: boolean
|
||||
sourceUrl?: string
|
||||
}
|
||||
|
||||
type RefreshModelCapabilitiesDeps = {
|
||||
loadConfig?: typeof loadPluginConfig
|
||||
refreshCache?: typeof refreshModelCapabilitiesCache
|
||||
stdout?: Pick<typeof process.stdout, "write">
|
||||
stderr?: Pick<typeof process.stderr, "write">
|
||||
}
|
||||
|
||||
export async function refreshModelCapabilities(
|
||||
options: RefreshModelCapabilitiesOptions,
|
||||
deps: RefreshModelCapabilitiesDeps = {},
|
||||
): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
const loadConfig = deps.loadConfig ?? loadPluginConfig
|
||||
const refreshCache = deps.refreshCache ?? refreshModelCapabilitiesCache
|
||||
const stdout = deps.stdout ?? process.stdout
|
||||
const stderr = deps.stderr ?? process.stderr
|
||||
|
||||
try {
|
||||
const config = loadConfig(directory, null)
|
||||
const sourceUrl = options.sourceUrl ?? config.model_capabilities?.source_url
|
||||
const snapshot = await refreshCache({ sourceUrl })
|
||||
|
||||
const summary = {
|
||||
sourceUrl: snapshot.sourceUrl,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
modelCount: Object.keys(snapshot.models).length,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
stdout.write(`${JSON.stringify(summary, null, 2)}\n`)
|
||||
} else {
|
||||
stdout.write(
|
||||
`Refreshed model capabilities cache (${summary.modelCount} models) from ${summary.sourceUrl}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
stderr.write(`Failed to refresh model capabilities cache: ${String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
||||
message: "Will you integrate Google Gemini?",
|
||||
options: [
|
||||
{ value: "no", label: "No", hint: "Frontend/docs agents will use fallback" },
|
||||
{ value: "yes", label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" },
|
||||
{ value: "yes", label: "Yes", hint: "Beautiful UI generation with Gemini 3.1 Pro" },
|
||||
],
|
||||
initialValue: initial.gemini,
|
||||
})
|
||||
|
||||
@@ -19,5 +19,6 @@ export type {
|
||||
SisyphusConfig,
|
||||
SisyphusTasksConfig,
|
||||
RuntimeFallbackConfig,
|
||||
ModelCapabilitiesConfig,
|
||||
FallbackModels,
|
||||
} from "./schema"
|
||||
|
||||
@@ -147,6 +147,37 @@ describe("disabled_mcps schema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("OhMyOpenCodeConfigSchema - model_capabilities", () => {
|
||||
test("accepts valid model capabilities config", () => {
|
||||
const input = {
|
||||
model_capabilities: {
|
||||
enabled: true,
|
||||
auto_refresh_on_start: true,
|
||||
refresh_timeout_ms: 5000,
|
||||
source_url: "https://models.dev/api.json",
|
||||
},
|
||||
}
|
||||
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(input)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.model_capabilities).toEqual(input.model_capabilities)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects invalid model capabilities config", () => {
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse({
|
||||
model_capabilities: {
|
||||
refresh_timeout_ms: -1,
|
||||
source_url: "not-a-url",
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AgentOverrideConfigSchema", () => {
|
||||
describe("category field", () => {
|
||||
test("accepts category as optional string", () => {
|
||||
@@ -371,6 +402,26 @@ describe("CategoryConfigSchema", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts reasoningEffort values none and minimal", () => {
|
||||
// given
|
||||
const noneConfig = { reasoningEffort: "none" }
|
||||
const minimalConfig = { reasoningEffort: "minimal" }
|
||||
|
||||
// when
|
||||
const noneResult = CategoryConfigSchema.safeParse(noneConfig)
|
||||
const minimalResult = CategoryConfigSchema.safeParse(minimalConfig)
|
||||
|
||||
// then
|
||||
expect(noneResult.success).toBe(true)
|
||||
expect(minimalResult.success).toBe(true)
|
||||
if (noneResult.success) {
|
||||
expect(noneResult.data.reasoningEffort).toBe("none")
|
||||
}
|
||||
if (minimalResult.success) {
|
||||
expect(minimalResult.data.reasoningEffort).toBe("minimal")
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects non-string variant", () => {
|
||||
// given
|
||||
const config = { model: "openai/gpt-5.4", variant: 123 }
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./schema/fallback-models"
|
||||
export * from "./schema/git-env-prefix"
|
||||
export * from "./schema/git-master"
|
||||
export * from "./schema/hooks"
|
||||
export * from "./schema/model-capabilities"
|
||||
export * from "./schema/notification"
|
||||
export * from "./schema/oh-my-opencode-config"
|
||||
export * from "./schema/ralph-loop"
|
||||
|
||||
@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
/** Text verbosity level. */
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
/** Provider-specific options. Passed directly to OpenCode SDK. */
|
||||
|
||||
@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
|
||||
budgetTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
prompt_append: z.string().optional(),
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const FallbackModelsSchema = z.union([z.string(), z.array(z.string())])
|
||||
export const FallbackModelObjectSchema = z.object({
|
||||
model: z.string(),
|
||||
variant: z.string().optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
thinking: z
|
||||
.object({
|
||||
type: z.enum(["enabled", "disabled"]),
|
||||
budgetTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type FallbackModelObject = z.infer<typeof FallbackModelObjectSchema>
|
||||
|
||||
export const FallbackModelsSchema = z.union([
|
||||
z.string(),
|
||||
z.array(z.union([z.string(), FallbackModelObjectSchema])),
|
||||
])
|
||||
|
||||
export type FallbackModels = z.infer<typeof FallbackModelsSchema>
|
||||
|
||||
@@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([
|
||||
"hashline-read-enhancer",
|
||||
"read-image-resizer",
|
||||
"todo-description-override",
|
||||
"webfetch-redirect-guard",
|
||||
])
|
||||
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const ModelCapabilitiesConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
auto_refresh_on_start: z.boolean().optional(),
|
||||
refresh_timeout_ms: z.number().int().positive().optional(),
|
||||
source_url: z.string().url().optional(),
|
||||
})
|
||||
|
||||
export type ModelCapabilitiesConfig = z.infer<typeof ModelCapabilitiesConfigSchema>
|
||||
@@ -13,6 +13,7 @@ import { ExperimentalConfigSchema } from "./experimental"
|
||||
import { GitMasterConfigSchema } from "./git-master"
|
||||
import { NotificationConfigSchema } from "./notification"
|
||||
import { OpenClawConfigSchema } from "./openclaw"
|
||||
import { ModelCapabilitiesConfigSchema } from "./model-capabilities"
|
||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||
import { SkillsConfigSchema } from "./skills"
|
||||
@@ -56,6 +57,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
notification: NotificationConfigSchema.optional(),
|
||||
model_capabilities: ModelCapabilitiesConfigSchema.optional(),
|
||||
openclaw: OpenClawConfigSchema.optional(),
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { BackgroundTask, LaunchInput } from "./types"
|
||||
export const TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000
|
||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 2_700_000
|
||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 3_600_000
|
||||
export const DEFAULT_MAX_TOOL_CALLS = 4000
|
||||
export const DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD = 20
|
||||
export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true
|
||||
|
||||
@@ -21,9 +21,9 @@ function createRunningTask(startedAt: Date): BackgroundTask {
|
||||
}
|
||||
|
||||
describe("DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS", () => {
|
||||
test("uses a 30 minute default", () => {
|
||||
test("uses a 60 minute default", () => {
|
||||
// #given
|
||||
const expectedTimeout = 30 * 60 * 1000
|
||||
const expectedTimeout = 60 * 60 * 1000
|
||||
|
||||
// #when
|
||||
const timeout = DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
|
||||
|
||||
@@ -4,9 +4,9 @@ const { describe, expect, test } = require("bun:test")
|
||||
import { DEFAULT_STALE_TIMEOUT_MS } from "./constants"
|
||||
|
||||
describe("DEFAULT_STALE_TIMEOUT_MS", () => {
|
||||
test("uses a 20 minute default", () => {
|
||||
test("uses a 45 minute default", () => {
|
||||
// #given
|
||||
const expectedTimeout = 20 * 60 * 1000
|
||||
const expectedTimeout = 45 * 60 * 1000
|
||||
|
||||
// #when
|
||||
const timeout = DEFAULT_STALE_TIMEOUT_MS
|
||||
|
||||
@@ -19,6 +19,8 @@ mock.module("../../shared/provider-model-id-transform", () => ({
|
||||
|
||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { shouldRetryError } from "../../shared/model-error-classifier"
|
||||
import { selectFallbackProvider } from "../../shared/model-error-classifier"
|
||||
import { readProviderModelsCache } from "../../shared"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -82,6 +84,8 @@ function createDefaultArgs(taskOverrides: Partial<BackgroundTask> = {}) {
|
||||
describe("tryFallbackRetry", () => {
|
||||
beforeEach(() => {
|
||||
;(shouldRetryError as any).mockImplementation(() => true)
|
||||
;(selectFallbackProvider as any).mockImplementation((providers: string[]) => providers[0])
|
||||
;(readProviderModelsCache as any).mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe("#given retryable error with fallback chain", () => {
|
||||
@@ -267,4 +271,24 @@ describe("tryFallbackRetry", () => {
|
||||
expect(args.task.attemptCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given disconnected fallback providers with connected preferred provider", () => {
|
||||
test("keeps fallback entry and selects connected preferred provider", () => {
|
||||
;(readProviderModelsCache as any).mockReturnValue({ connected: ["provider-a"] })
|
||||
;(selectFallbackProvider as any).mockImplementation(
|
||||
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
|
||||
)
|
||||
|
||||
const args = createDefaultArgs({
|
||||
fallbackChain: [{ model: "fallback-model-1", providers: ["provider-b"], variant: undefined }],
|
||||
model: { providerID: "provider-a", modelID: "original-model" },
|
||||
})
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(args.task.model?.providerID).toBe("provider-a")
|
||||
expect(args.task.model?.modelID).toBe("fallback-model-1")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,10 +35,14 @@ export function tryFallbackRetry(args: {
|
||||
const providerModelsCache = readProviderModelsCache()
|
||||
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders.map(p => p.toLowerCase())) : null
|
||||
const preferredProvider = task.model?.providerID?.toLowerCase()
|
||||
|
||||
const isReachable = (entry: FallbackEntry): boolean => {
|
||||
if (!connectedSet) return true
|
||||
return entry.providers.some((p) => connectedSet.has(p.toLowerCase()))
|
||||
if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) {
|
||||
return true
|
||||
}
|
||||
return preferredProvider ? connectedSet.has(preferredProvider) : false
|
||||
}
|
||||
|
||||
let selectedAttemptCount = attemptCount
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createToolCallSignature,
|
||||
@@ -19,7 +21,7 @@ function buildWindow(
|
||||
}
|
||||
|
||||
function buildWindowWithInputs(
|
||||
calls: Array<{ tool: string; input?: Record<string, unknown> }>,
|
||||
calls: Array<{ tool: string; input?: Record<string, unknown> | null }>,
|
||||
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
|
||||
) {
|
||||
const settings = resolveCircuitBreakerSettings(override)
|
||||
@@ -148,7 +150,12 @@ describe("loop-detector", () => {
|
||||
|
||||
describe("#given the same tool is called consecutively", () => {
|
||||
test("#when evaluated #then it triggers", () => {
|
||||
const window = buildWindow(Array.from({ length: 20 }, () => "read"))
|
||||
const window = buildWindowWithInputs(
|
||||
Array.from({ length: 20 }, () => ({
|
||||
tool: "read",
|
||||
input: { filePath: "/src/same.ts" },
|
||||
}))
|
||||
)
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
@@ -176,7 +183,12 @@ describe("loop-detector", () => {
|
||||
|
||||
describe("#given threshold boundary", () => {
|
||||
test("#when below threshold #then it does not trigger", () => {
|
||||
const belowThresholdWindow = buildWindow(Array.from({ length: 19 }, () => "read"))
|
||||
const belowThresholdWindow = buildWindowWithInputs(
|
||||
Array.from({ length: 19 }, () => ({
|
||||
tool: "read",
|
||||
input: { filePath: "/src/same.ts" },
|
||||
}))
|
||||
)
|
||||
|
||||
const result = detectRepetitiveToolUse(belowThresholdWindow)
|
||||
|
||||
@@ -184,7 +196,12 @@ describe("loop-detector", () => {
|
||||
})
|
||||
|
||||
test("#when equal to threshold #then it triggers", () => {
|
||||
const atThresholdWindow = buildWindow(Array.from({ length: 20 }, () => "read"))
|
||||
const atThresholdWindow = buildWindowWithInputs(
|
||||
Array.from({ length: 20 }, () => ({
|
||||
tool: "read",
|
||||
input: { filePath: "/src/same.ts" },
|
||||
}))
|
||||
)
|
||||
|
||||
const result = detectRepetitiveToolUse(atThresholdWindow)
|
||||
|
||||
@@ -224,16 +241,22 @@ describe("loop-detector", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given tool calls with no input", () => {
|
||||
test("#when evaluated #then it triggers", () => {
|
||||
describe("#given tool calls with undefined input", () => {
|
||||
test("#when evaluated #then it does not trigger", () => {
|
||||
const calls = Array.from({ length: 20 }, () => ({ tool: "read" }))
|
||||
const window = buildWindowWithInputs(calls)
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 20,
|
||||
})
|
||||
expect(result).toEqual({ triggered: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given tool calls with null input", () => {
|
||||
test("#when evaluated #then it does not trigger", () => {
|
||||
const calls = Array.from({ length: 20 }, () => ({ tool: "read", input: null }))
|
||||
const window = buildWindowWithInputs(calls)
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({ triggered: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,14 @@ export function recordToolCall(
|
||||
settings: CircuitBreakerSettings,
|
||||
toolInput?: Record<string, unknown> | null
|
||||
): ToolCallWindow {
|
||||
if (toolInput === undefined || toolInput === null) {
|
||||
return {
|
||||
lastSignature: `${toolName}::__unknown-input__`,
|
||||
consecutiveCount: 1,
|
||||
threshold: settings.consecutiveThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
const signature = createToolCallSignature(toolName, toolInput)
|
||||
|
||||
if (window && window.lastSignature === signature) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -38,8 +40,8 @@ async function flushAsyncWork() {
|
||||
}
|
||||
|
||||
describe("BackgroundManager circuit breaker", () => {
|
||||
describe("#given the same tool is called consecutively", () => {
|
||||
test("#when consecutive tool events arrive #then the task is cancelled", async () => {
|
||||
describe("#given flat-format tool events have no state.input", () => {
|
||||
test("#when 20 consecutive read events arrive #then the task keeps running", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
consecutiveThreshold: 20,
|
||||
@@ -71,8 +73,8 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("read 20 consecutive times")
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,7 +128,7 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
})
|
||||
|
||||
describe("#given the absolute cap is configured lower than the repetition detector needs", () => {
|
||||
test("#when the raw tool-call cap is reached #then the backstop still cancels the task", async () => {
|
||||
test("#when repeated flat-format tool events reach maxToolCalls #then the backstop still cancels the task", async () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 3,
|
||||
circuitBreaker: {
|
||||
@@ -150,10 +152,10 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of ["read", "grep", "edit"]) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: "read" },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||
import { tmpdir } from "node:os"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
@@ -1636,6 +1637,9 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearSessionPromptParams("session-1")
|
||||
clearSessionPromptParams("session-advanced")
|
||||
clearSessionPromptParams("session-2")
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
@@ -1671,6 +1675,60 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
expect(promptCalls[0].body.agent).toBe("explore")
|
||||
})
|
||||
|
||||
test("should preserve promoted per-model settings when resuming a task", async () => {
|
||||
// given - task resumed after fallback promotion
|
||||
const taskWithAdvancedModel: BackgroundTask = {
|
||||
id: "task-with-advanced-model",
|
||||
sessionID: "session-advanced",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task with advanced model settings",
|
||||
prompt: "original prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
variant: "minimal",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.25,
|
||||
top_p: 0.55,
|
||||
maxTokens: 8192,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
getTaskMap(manager).set(taskWithAdvancedModel.id, taskWithAdvancedModel)
|
||||
|
||||
// when
|
||||
await manager.resume({
|
||||
sessionId: "session-advanced",
|
||||
prompt: "continue the work",
|
||||
parentSessionID: "parent-session-2",
|
||||
parentMessageID: "msg-2",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
})
|
||||
expect(promptCalls[0].body.variant).toBe("minimal")
|
||||
expect(promptCalls[0].body.options).toBeUndefined()
|
||||
expect(getSessionPromptParams("session-advanced")).toEqual({
|
||||
temperature: 0.25,
|
||||
topP: 0.55,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 8192,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should NOT pass model when task has no model (backward compatibility)", async () => {
|
||||
// given - task without model (default behavior)
|
||||
const taskWithoutModel: BackgroundTask = {
|
||||
@@ -1806,9 +1864,9 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(task.sessionID).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should return immediately even with concurrency limit", async () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
test("should return immediately even with concurrency limit", async () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
|
||||
|
||||
@@ -1828,9 +1886,76 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
// then
|
||||
expect(endTime - startTime).toBeLessThan(100) // Should be instant
|
||||
expect(task1.status).toBe("pending")
|
||||
expect(task2.status).toBe("pending")
|
||||
expect(task1.status).toBe("pending")
|
||||
expect(task2.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("should keep agent when launch has model and keep agent without model", async () => {
|
||||
// given
|
||||
const promptBodies: Array<Record<string, unknown>> = []
|
||||
let resolveFirstPromptStarted: (() => void) | undefined
|
||||
let resolveSecondPromptStarted: (() => void) | undefined
|
||||
const firstPromptStarted = new Promise<void>((resolve) => {
|
||||
resolveFirstPromptStarted = resolve
|
||||
})
|
||||
const secondPromptStarted = new Promise<void>((resolve) => {
|
||||
resolveSecondPromptStarted = resolve
|
||||
})
|
||||
const customClient = {
|
||||
session: {
|
||||
create: async (_args?: unknown) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
|
||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptBodies.push(args.body)
|
||||
if (promptBodies.length === 1) {
|
||||
resolveFirstPromptStarted?.()
|
||||
}
|
||||
if (promptBodies.length === 2) {
|
||||
resolveSecondPromptStarted?.()
|
||||
}
|
||||
return {}
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ client: customClient, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const launchInputWithModel = {
|
||||
description: "Test task with model",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
const launchInputWithoutModel = {
|
||||
description: "Test task without model",
|
||||
prompt: "Do something else",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
// when
|
||||
const taskWithModel = await manager.launch(launchInputWithModel)
|
||||
await firstPromptStarted
|
||||
const taskWithoutModel = await manager.launch(launchInputWithoutModel)
|
||||
await secondPromptStarted
|
||||
|
||||
// then
|
||||
expect(taskWithModel.status).toBe("pending")
|
||||
expect(taskWithoutModel.status).toBe("pending")
|
||||
expect(promptBodies).toHaveLength(2)
|
||||
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
expect(promptBodies[0].agent).toBe("test-agent")
|
||||
expect(promptBodies[1].agent).toBe("test-agent")
|
||||
expect("model" in promptBodies[1]).toBe(false)
|
||||
})
|
||||
|
||||
test("should queue multiple tasks without blocking", async () => {
|
||||
// given
|
||||
@@ -2359,6 +2484,133 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(abortCalls).toEqual([createdSessionID])
|
||||
expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0)
|
||||
})
|
||||
|
||||
test("should release descendant quota when task completes", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
const internalTask = getTaskMap(manager).get(task.id)!
|
||||
internalTask.status = "running"
|
||||
internalTask.sessionID = "child-session-complete"
|
||||
internalTask.rootSessionID = "session-root"
|
||||
|
||||
// Complete via internal method (session.status events go through the poller, not handleEvent)
|
||||
await tryCompleteTaskForTest(manager, internalTask)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should release descendant quota when running task is cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
const internalTask = getTaskMap(manager).get(task.id)!
|
||||
internalTask.status = "running"
|
||||
internalTask.sessionID = "child-session-cancel"
|
||||
|
||||
await manager.cancelTask(task.id)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should release descendant quota when task errors", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
const internalTask = getTaskMap(manager).get(task.id)!
|
||||
internalTask.status = "running"
|
||||
internalTask.sessionID = "child-session-error"
|
||||
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: { sessionID: internalTask.sessionID, info: { id: internalTask.sessionID } },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("should not double-decrement quota when pending task is cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 2 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-root",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
const task1 = await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
|
||||
await manager.cancelTask(task1.id)
|
||||
await manager.cancelTask(task2.id)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pending task can be cancelled", () => {
|
||||
@@ -2781,6 +3033,18 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
})
|
||||
|
||||
describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
const originalDateNow = Date.now
|
||||
let fixedTime: number
|
||||
|
||||
beforeEach(() => {
|
||||
fixedTime = Date.now()
|
||||
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = originalDateNow
|
||||
})
|
||||
|
||||
test("should NOT interrupt task running less than 30 seconds (min runtime guard)", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
@@ -3027,10 +3291,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 25 * 60 * 1000),
|
||||
startedAt: new Date(Date.now() - 50 * 60 * 1000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 21 * 60 * 1000),
|
||||
lastUpdate: new Date(Date.now() - 46 * 60 * 1000),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4673,6 +4937,53 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("startTask keeps agent when explicit model is configured", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||
create: async () => ({ data: { id: "session-1" } }),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-explicit-model",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: "test task",
|
||||
prompt: "test prompt",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
||||
}
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: task.model,
|
||||
}
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> })
|
||||
.startTask({ task, input })
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.agent).toBe("sisyphus-junior")
|
||||
expect(promptCalls[0].body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
||||
expect(promptCalls[0].body.variant).toBe("medium")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("resume respects explore agent restrictions", async () => {
|
||||
//#given
|
||||
let capturedTools: Record<string, unknown> | undefined
|
||||
@@ -4717,4 +5028,48 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("resume keeps agent when explicit model is configured", async () => {
|
||||
//#given
|
||||
let promptCall: { path: { id: string }; body: Record<string, unknown> } | undefined
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCall = args
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-explicit-model-resume",
|
||||
sessionID: "session-3",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
description: "resume task",
|
||||
prompt: "resume prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-3",
|
||||
prompt: "continue",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptCall).toBeDefined()
|
||||
expect(promptCall?.body.agent).toBe("explore")
|
||||
expect(promptCall?.body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
createInternalAgentTextPart,
|
||||
} from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
@@ -504,14 +505,20 @@ export class BackgroundManager {
|
||||
})
|
||||
|
||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
||||
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
|
||||
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
|
||||
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
||||
// Temperature/topP and provider-specific options are applied through chat.params.
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
if (input.model) {
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
}
|
||||
|
||||
promptWithModelSuggestionRetry(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
@@ -543,6 +550,9 @@ export class BackgroundManager {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.rootSessionID) {
|
||||
this.unregisterRootDescendant(existingTask.rootSessionID)
|
||||
}
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
@@ -782,13 +792,19 @@ export class BackgroundManager {
|
||||
})
|
||||
|
||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||
// Include model if task has one (preserved from original launch with category config)
|
||||
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
|
||||
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
|
||||
const resumeModel = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
? {
|
||||
providerID: existingTask.model.providerID,
|
||||
modelID: existingTask.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
if (existingTask.model) {
|
||||
applySessionPromptParams(existingTask.sessionID!, existingTask.model)
|
||||
}
|
||||
|
||||
this.client.session.promptAsync({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
@@ -813,6 +829,9 @@ export class BackgroundManager {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.rootSessionID) {
|
||||
this.unregisterRootDescendant(existingTask.rootSessionID)
|
||||
}
|
||||
|
||||
// Release concurrency on error to prevent slot leaks
|
||||
if (existingTask.concurrencyKey) {
|
||||
@@ -1009,6 +1028,9 @@ export class BackgroundManager {
|
||||
task.status = "error"
|
||||
task.error = errorMsg
|
||||
task.completedAt = new Date()
|
||||
if (task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
@@ -1341,8 +1363,12 @@ export class BackgroundManager {
|
||||
log("[background-agent] Cancelled pending task:", { taskId, key })
|
||||
}
|
||||
|
||||
const wasRunning = task.status === "running"
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
if (wasRunning && task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
if (reason) {
|
||||
task.error = reason
|
||||
}
|
||||
@@ -1463,6 +1489,10 @@ export class BackgroundManager {
|
||||
task.completedAt = new Date()
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
if (task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
|
||||
removeTaskToastTracking(task.id)
|
||||
|
||||
// Release concurrency BEFORE any async operations to prevent slot leaks
|
||||
@@ -1701,6 +1731,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
task.status = "error"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
if (!wasPending && task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
|
||||
@@ -1,33 +1,120 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
import { describe, test, expect, mock, afterEach } from "bun:test"
|
||||
import { createTask, startTask } from "./spawner"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import {
|
||||
clearSessionPromptParams,
|
||||
getSessionPromptParams,
|
||||
} from "../../shared/session-prompt-params-state"
|
||||
|
||||
describe("background-agent spawner.startTask", () => {
|
||||
test("applies explicit child session permission rules when creating child session", async () => {
|
||||
describe("background-agent spawner fallback model promotion", () => {
|
||||
afterEach(() => {
|
||||
clearSessionPromptParams("session-123")
|
||||
})
|
||||
|
||||
test("passes promoted fallback model settings through supported prompt channels", async () => {
|
||||
//#given
|
||||
const createCalls: any[] = []
|
||||
const parentPermission = [
|
||||
{ permission: "question", action: "allow" as const, pattern: "*" },
|
||||
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
|
||||
]
|
||||
let promptArgs: any
|
||||
const client = {
|
||||
session: {
|
||||
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
|
||||
create: mock(async () => ({ data: { id: "session-123" } })),
|
||||
promptAsync: mock(async (input: any) => {
|
||||
promptArgs = input
|
||||
return { data: {} }
|
||||
}),
|
||||
},
|
||||
} as any
|
||||
|
||||
const concurrencyManager = {
|
||||
release: mock(() => {}),
|
||||
} as any
|
||||
|
||||
const onTaskError = mock(() => {})
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "bg_test123",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: "Test task",
|
||||
prompt: "Do the thing",
|
||||
agent: "oracle",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "message-1",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.4,
|
||||
top_p: 0.7,
|
||||
maxTokens: 4096,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
}
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do the thing",
|
||||
agent: "oracle",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "message-1",
|
||||
model: task.model,
|
||||
}
|
||||
|
||||
//#when
|
||||
await startTask(
|
||||
{ task, input },
|
||||
{
|
||||
client,
|
||||
directory: "/tmp/test",
|
||||
concurrencyManager,
|
||||
tmuxEnabled: false,
|
||||
onTaskError,
|
||||
},
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
//#then
|
||||
expect(promptArgs.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
expect(promptArgs.body.variant).toBe("low")
|
||||
expect(promptArgs.body.options).toBeUndefined()
|
||||
expect(getSessionPromptParams("session-123")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps agent when explicit model is configured", async () => {
|
||||
//#given
|
||||
const promptCalls: any[] = []
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
|
||||
create: async (args?: any) => {
|
||||
createCalls.push(args)
|
||||
return { data: { id: "ses_child" } }
|
||||
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||
create: async () => ({ data: { id: "ses_child" } }),
|
||||
promptAsync: async (args?: any) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
promptAsync: async () => ({}),
|
||||
},
|
||||
}
|
||||
|
||||
const task = createTask({
|
||||
description: "Test task",
|
||||
prompt: "Do work",
|
||||
agent: "explore",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionID: "ses_parent",
|
||||
parentMessageID: "msg_parent",
|
||||
model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
||||
})
|
||||
|
||||
const item = {
|
||||
@@ -41,9 +128,6 @@ describe("background-agent spawner.startTask", () => {
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
model: task.model,
|
||||
sessionPermission: [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,9 +143,12 @@ describe("background-agent spawner.startTask", () => {
|
||||
await startTask(item as any, ctx as any)
|
||||
|
||||
//#then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(createCalls[0]?.body?.permission).toEqual([
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
||||
expect(promptCalls[0]?.body?.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
@@ -128,10 +129,15 @@ export async function startTask(
|
||||
})
|
||||
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
@@ -213,10 +219,15 @@ export async function resumeTask(
|
||||
})
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
? {
|
||||
providerID: task.model.providerID,
|
||||
modelID: task.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
applySessionPromptParams(task.sessionID, task.model)
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: task.sessionID },
|
||||
body: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, it, expect, mock } = require("bun:test")
|
||||
const { describe, it, expect, mock, spyOn, beforeEach, afterEach } = require("bun:test")
|
||||
|
||||
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import type { BackgroundTask } from "./types"
|
||||
@@ -29,6 +29,18 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
const originalDateNow = Date.now
|
||||
let fixedTime: number
|
||||
|
||||
beforeEach(() => {
|
||||
fixedTime = Date.now()
|
||||
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = originalDateNow
|
||||
})
|
||||
|
||||
|
||||
it("should interrupt tasks with lastUpdate exceeding stale timeout", async () => {
|
||||
//#given
|
||||
@@ -117,13 +129,13 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
})
|
||||
|
||||
it("should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when messageStalenessTimeoutMs is not configured", async () => {
|
||||
//#given — task started 35 minutes ago, no config for messageStalenessTimeoutMs
|
||||
//#given — task started 65 minutes ago, no config for messageStalenessTimeoutMs
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 35 * 60 * 1000),
|
||||
startedAt: new Date(Date.now() - 65 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when — default is 30 minutes (1_800_000ms)
|
||||
//#when — default is 60 minutes (3_600_000ms)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function checkAndInterruptStaleTasks(args: {
|
||||
|
||||
const staleMinutes = Math.round(runtime / 60000)
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
|
||||
task.error = `Stale timeout (no activity for ${staleMinutes}min since start). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.staleTimeoutMs' in .opencode/oh-my-opencode.json.`
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
@@ -159,10 +159,10 @@ export async function checkAndInterruptStaleTasks(args: {
|
||||
if (timeSinceLastUpdate <= staleTimeoutMs) continue
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (no activity for ${staleMinutes}min)`
|
||||
task.completedAt = new Date()
|
||||
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (no activity for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.staleTimeoutMs' in .opencode/oh-my-opencode.json.`
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
|
||||
|
||||
export type BackgroundTaskStatus =
|
||||
@@ -43,7 +44,7 @@ export interface BackgroundTask {
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
model?: { providerID: string; modelID: string; variant?: string }
|
||||
model?: DelegatedModelConfig
|
||||
/** Fallback chain for runtime retry on model errors */
|
||||
fallbackChain?: FallbackEntry[]
|
||||
/** Number of fallback retry attempts made */
|
||||
@@ -76,7 +77,7 @@ export interface LaunchInput {
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
parentAgent?: string
|
||||
parentTools?: Record<string, boolean>
|
||||
model?: { providerID: string; modelID: string; variant?: string }
|
||||
model?: DelegatedModelConfig
|
||||
/** Fallback chain for runtime retry on model errors */
|
||||
fallbackChain?: FallbackEntry[]
|
||||
isUnstableAgent?: boolean
|
||||
|
||||
@@ -481,7 +481,7 @@ describe("boulder-state", () => {
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should return isComplete true for empty plan", () => {
|
||||
test("should return isComplete false for empty plan", () => {
|
||||
// given - plan with no checkboxes
|
||||
const planPath = join(TEST_DIR, "empty-plan.md")
|
||||
writeFileSync(planPath, "# Plan\nNo tasks here")
|
||||
@@ -491,7 +491,7 @@ describe("boulder-state", () => {
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should handle non-existent file", () => {
|
||||
|
||||
@@ -186,7 +186,7 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
isComplete: total === 0 || completed === total,
|
||||
isComplete: total > 0 && completed === total,
|
||||
}
|
||||
} catch {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import type { PluginComponentsResult } from "./loader"
|
||||
|
||||
describe("loadAllPluginComponents", () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCODE_DISABLE_CLAUDE_CODE
|
||||
delete process.env.OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
describe("when OPENCODE_DISABLE_CLAUDE_CODE is set to 'true'", () => {
|
||||
it("returns empty result without loading any plugins", async () => {
|
||||
// given
|
||||
process.env.OPENCODE_DISABLE_CLAUDE_CODE = "true"
|
||||
|
||||
// when
|
||||
const { loadAllPluginComponents } = await import("./loader")
|
||||
const result: PluginComponentsResult = await loadAllPluginComponents()
|
||||
|
||||
// then
|
||||
expect(result.commands).toEqual({})
|
||||
expect(result.skills).toEqual({})
|
||||
expect(result.agents).toEqual({})
|
||||
expect(result.mcpServers).toEqual({})
|
||||
expect(result.hooksConfigs).toEqual([])
|
||||
expect(result.plugins).toEqual([])
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("when OPENCODE_DISABLE_CLAUDE_CODE is set to '1'", () => {
|
||||
it("returns empty result without loading any plugins", async () => {
|
||||
// given
|
||||
process.env.OPENCODE_DISABLE_CLAUDE_CODE = "1"
|
||||
|
||||
// when
|
||||
const { loadAllPluginComponents } = await import("./loader")
|
||||
const result: PluginComponentsResult = await loadAllPluginComponents()
|
||||
|
||||
// then
|
||||
expect(result.commands).toEqual({})
|
||||
expect(result.plugins).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("when OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS is set to 'true'", () => {
|
||||
it("returns empty result without loading any plugins", async () => {
|
||||
// given
|
||||
process.env.OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS = "true"
|
||||
|
||||
// when
|
||||
const { loadAllPluginComponents } = await import("./loader")
|
||||
const result: PluginComponentsResult = await loadAllPluginComponents()
|
||||
|
||||
// then
|
||||
expect(result.commands).toEqual({})
|
||||
expect(result.plugins).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("when OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS is set to '1'", () => {
|
||||
it("returns empty result without loading any plugins", async () => {
|
||||
// given
|
||||
process.env.OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS = "1"
|
||||
|
||||
// when
|
||||
const { loadAllPluginComponents } = await import("./loader")
|
||||
const result: PluginComponentsResult = await loadAllPluginComponents()
|
||||
|
||||
// then
|
||||
expect(result.commands).toEqual({})
|
||||
expect(result.plugins).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("when neither env var is set", () => {
|
||||
it("does not skip plugin loading", async () => {
|
||||
// given
|
||||
delete process.env.OPENCODE_DISABLE_CLAUDE_CODE
|
||||
delete process.env.OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS
|
||||
|
||||
// when
|
||||
const { loadAllPluginComponents } = await import("./loader")
|
||||
const result: PluginComponentsResult = await loadAllPluginComponents()
|
||||
|
||||
// then — should attempt to load (may find 0 plugins, but shouldn't early-return)
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toHaveProperty("commands")
|
||||
expect(result).toHaveProperty("plugins")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when env var is set to unrecognized value", () => {
|
||||
it("does not skip plugin loading", async () => {
|
||||
// given
|
||||
process.env.OPENCODE_DISABLE_CLAUDE_CODE = "yes"
|
||||
|
||||
// when
|
||||
const { loadAllPluginComponents } = await import("./loader")
|
||||
const result: PluginComponentsResult = await loadAllPluginComponents()
|
||||
|
||||
// then — "yes" is not "true" or "1", should not skip
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toHaveProperty("plugins")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,26 @@ export interface PluginComponentsResult {
|
||||
errors: PluginLoadError[]
|
||||
}
|
||||
|
||||
function isClaudeCodePluginsDisabled(): boolean {
|
||||
const disableFlag = process.env.OPENCODE_DISABLE_CLAUDE_CODE
|
||||
const disablePluginsFlag = process.env.OPENCODE_DISABLE_CLAUDE_CODE_PLUGINS
|
||||
return disableFlag === "true" || disableFlag === "1" || disablePluginsFlag === "true" || disablePluginsFlag === "1"
|
||||
}
|
||||
|
||||
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
|
||||
if (isClaudeCodePluginsDisabled()) {
|
||||
log("Claude Code plugin loading disabled via OPENCODE_DISABLE_CLAUDE_CODE env var")
|
||||
return {
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
}
|
||||
}
|
||||
|
||||
const { plugins, errors } = discoverInstalledPlugins(options)
|
||||
|
||||
const [commands, skills, agents, mcpServers, hooksConfigs] = await Promise.all([
|
||||
|
||||
@@ -1,44 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { startCallbackServer, type CallbackServer } from "./callback-server"
|
||||
|
||||
const HOSTNAME = "127.0.0.1"
|
||||
const nativeFetch = Bun.fetch.bind(Bun)
|
||||
|
||||
function supportsRealSocketBinding(): boolean {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("probe"),
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const canBindRealSockets = supportsRealSocketBinding()
|
||||
|
||||
type MockServerState = {
|
||||
port: number
|
||||
stopped: boolean
|
||||
fetch: (request: Request) => Response | Promise<Response>
|
||||
}
|
||||
|
||||
describe("startCallbackServer", () => {
|
||||
let server: CallbackServer | null = null
|
||||
let serveSpy: ReturnType<typeof spyOn> | null = null
|
||||
let activeServer: MockServerState | null = null
|
||||
|
||||
async function request(url: string): Promise<Response> {
|
||||
if (canBindRealSockets) {
|
||||
return nativeFetch(url)
|
||||
}
|
||||
|
||||
if (!activeServer || activeServer.stopped) {
|
||||
throw new Error("Connection refused")
|
||||
}
|
||||
|
||||
return await activeServer.fetch(new Request(url))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (canBindRealSockets) {
|
||||
return
|
||||
}
|
||||
|
||||
activeServer = null
|
||||
serveSpy = spyOn(Bun, "serve").mockImplementation((options: {
|
||||
port: number
|
||||
hostname?: string
|
||||
fetch: (request: Request) => Response | Promise<Response>
|
||||
}) => {
|
||||
const state: MockServerState = {
|
||||
port: options.port === 0 ? 19877 : options.port,
|
||||
stopped: false,
|
||||
fetch: options.fetch,
|
||||
}
|
||||
|
||||
const handle = {
|
||||
port: state.port,
|
||||
stop: (_force?: boolean) => {
|
||||
state.stopped = true
|
||||
if (activeServer === state) {
|
||||
activeServer = null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
activeServer = state
|
||||
return handle as ReturnType<typeof Bun.serve>
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
server?.close()
|
||||
server = null
|
||||
// Allow time for port to be released before next test
|
||||
await Bun.sleep(10)
|
||||
|
||||
if (serveSpy) {
|
||||
serveSpy.mockRestore()
|
||||
serveSpy = null
|
||||
}
|
||||
activeServer = null
|
||||
|
||||
if (canBindRealSockets) {
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
})
|
||||
|
||||
it("starts server and returns port", async () => {
|
||||
// given - no preconditions
|
||||
|
||||
// when
|
||||
server = await startCallbackServer()
|
||||
|
||||
// then
|
||||
expect(server.port).toBeGreaterThanOrEqual(19877)
|
||||
expect(typeof server.waitForCallback).toBe("function")
|
||||
expect(typeof server.close).toBe("function")
|
||||
})
|
||||
|
||||
it("resolves callback with code and state from query params", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
const callbackUrl = `http://${HOSTNAME}:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
|
||||
// when
|
||||
// Use Promise.all to ensure fetch and waitForCallback run concurrently
|
||||
// This prevents race condition where waitForCallback blocks before fetch starts
|
||||
const [result, response] = await Promise.all([
|
||||
server.waitForCallback(),
|
||||
nativeFetch(callbackUrl)
|
||||
request(callbackUrl),
|
||||
])
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ code: "test-code", state: "test-state" })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
@@ -46,25 +114,19 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("returns 404 for non-callback routes", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`)
|
||||
const response = await request(`http://${HOSTNAME}:${server.port}/other`)
|
||||
|
||||
// then
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when code is missing", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
const callbackRejection = server.waitForCallback().catch((error: Error) => error)
|
||||
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
|
||||
const response = await request(`http://${HOSTNAME}:${server.port}/oauth/callback?state=s`)
|
||||
|
||||
// then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -72,14 +134,11 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when state is missing", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
const callbackRejection = server.waitForCallback().catch((error: Error) => error)
|
||||
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
|
||||
const response = await request(`http://${HOSTNAME}:${server.port}/oauth/callback?code=c`)
|
||||
|
||||
// then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -87,18 +146,15 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("close stops the server immediately", async () => {
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const port = server.port
|
||||
|
||||
// when
|
||||
server.close()
|
||||
server = null
|
||||
|
||||
// then
|
||||
try {
|
||||
await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
|
||||
expect(true).toBe(false)
|
||||
await request(`http://${HOSTNAME}:${port}/oauth/callback?code=c&state=s`)
|
||||
expect.unreachable("request should fail after close")
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promi
|
||||
}
|
||||
|
||||
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {
|
||||
const port = await findAvailablePort(startPort)
|
||||
const requestedPort = await findAvailablePort(startPort).catch(() => 0)
|
||||
|
||||
let resolveCallback: ((result: OAuthCallbackResult) => void) | null = null
|
||||
let rejectCallback: ((error: Error) => void) | null = null
|
||||
@@ -55,7 +55,7 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
port: requestedPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
@@ -93,9 +93,10 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
|
||||
})
|
||||
},
|
||||
})
|
||||
const activePort = server.port ?? requestedPort
|
||||
|
||||
return {
|
||||
port,
|
||||
port: activePort,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
@@ -90,6 +90,69 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to root well-known URL when resource has a sub-path", () => {
|
||||
// given — resource URL has a /mcp path (e.g. https://mcp.sentry.dev/mcp)
|
||||
const resource = "https://mcp.example.com/mcp"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const pathSuffixedAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server/mcp"
|
||||
const rootAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server"
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
if (url === pathSuffixedAsUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
if (url === rootAsUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://mcp.example.com/oauth/authorize",
|
||||
token_endpoint: "https://mcp.example.com/oauth/token",
|
||||
registration_endpoint: "https://mcp.example.com/oauth/register",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://mcp.example.com/oauth/authorize",
|
||||
tokenEndpoint: "https://mcp.example.com/oauth/token",
|
||||
registrationEndpoint: "https://mcp.example.com/oauth/register",
|
||||
resource,
|
||||
})
|
||||
expect(calls).toEqual([prmUrl, pathSuffixedAsUrl, rootAsUrl])
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when PRM, path-suffixed AS, and root AS all return 404", () => {
|
||||
// given
|
||||
const resource = "https://mcp.example.com/mcp"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
if (url === prmUrl || url.includes(".well-known/oauth-authorization-server")) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// then
|
||||
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
|
||||
})
|
||||
|
||||
test("throws when both PRM and AS discovery return 404", () => {
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
|
||||
@@ -36,28 +36,16 @@ async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<stri
|
||||
return { ok: true, json }
|
||||
}
|
||||
|
||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
||||
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
|
||||
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
|
||||
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
|
||||
const metadata = await fetchMetadata(metadataUrl)
|
||||
|
||||
if (!metadata.ok) {
|
||||
if (metadata.status === 404) {
|
||||
throw new Error("OAuth authorization server metadata not found")
|
||||
}
|
||||
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
|
||||
}
|
||||
|
||||
function parseMetadataFields(json: Record<string, unknown>, resource: string): OAuthServerMetadata {
|
||||
const authorizationEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "authorization_endpoint"),
|
||||
readStringField(json, "authorization_endpoint"),
|
||||
"authorization_endpoint"
|
||||
).toString()
|
||||
const tokenEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "token_endpoint"),
|
||||
readStringField(json, "token_endpoint"),
|
||||
"token_endpoint"
|
||||
).toString()
|
||||
const registrationEndpointValue = metadata.json.registration_endpoint
|
||||
const registrationEndpointValue = json.registration_endpoint
|
||||
const registrationEndpoint =
|
||||
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
|
||||
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
|
||||
@@ -71,6 +59,29 @@ async function fetchAuthorizationServerMetadata(issuer: string, resource: string
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
||||
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
|
||||
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
|
||||
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
|
||||
const metadata = await fetchMetadata(metadataUrl)
|
||||
|
||||
if (!metadata.ok) {
|
||||
if (metadata.status === 404 && issuerPath !== "") {
|
||||
const rootMetadataUrl = new URL("/.well-known/oauth-authorization-server", issuerUrl).toString()
|
||||
const rootMetadata = await fetchMetadata(rootMetadataUrl)
|
||||
if (rootMetadata.ok) {
|
||||
return parseMetadataFields(rootMetadata.json, resource)
|
||||
}
|
||||
}
|
||||
if (metadata.status === 404) {
|
||||
throw new Error("OAuth authorization server metadata not found")
|
||||
}
|
||||
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
|
||||
}
|
||||
|
||||
return parseMetadataFields(metadata.json, resource)
|
||||
}
|
||||
|
||||
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
|
||||
const servers = metadata.authorization_servers
|
||||
if (!Array.isArray(servers)) return []
|
||||
|
||||
@@ -226,6 +226,29 @@ describe('TmuxSessionManager', () => {
|
||||
// then
|
||||
expect(manager).toBeDefined()
|
||||
})
|
||||
|
||||
test('falls back to default port when serverUrl has port 0', async () => {
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = {
|
||||
...createMockContext(),
|
||||
serverUrl: new URL('http://127.0.0.1:0/'),
|
||||
}
|
||||
const config: TmuxConfig = {
|
||||
enabled: true,
|
||||
layout: 'main-vertical',
|
||||
main_pane_size: 60,
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
|
||||
// when
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
// then
|
||||
expect((manager as any).serverUrl).toBe('http://localhost:4096')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSessionCreated', () => {
|
||||
|
||||
@@ -73,10 +73,18 @@ export class TmuxSessionManager {
|
||||
this.tmuxConfig = tmuxConfig
|
||||
this.deps = deps
|
||||
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
||||
const fallbackUrl = `http://localhost:${defaultPort}`
|
||||
try {
|
||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||
const raw = ctx.serverUrl?.toString()
|
||||
if (raw) {
|
||||
const parsed = new URL(raw)
|
||||
const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
|
||||
this.serverUrl = port === '0' ? fallbackUrl : raw
|
||||
} else {
|
||||
this.serverUrl = fallbackUrl
|
||||
}
|
||||
} catch {
|
||||
this.serverUrl = `http://localhost:${defaultPort}`
|
||||
this.serverUrl = fallbackUrl
|
||||
}
|
||||
this.sourcePaneId = deps.getCurrentPaneId()
|
||||
this.pollingManager = new TmuxPollingManager(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,9 +98,9 @@ describe("runSummarizeRetryStrategy", () => {
|
||||
}) as typeof setTimeout
|
||||
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 1,
|
||||
attempt: 0,
|
||||
lastAttemptTime: Date.now(),
|
||||
firstAttemptTime: Date.now() - 119700,
|
||||
firstAttemptTime: Date.now() - 119900,
|
||||
})
|
||||
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
|
||||
|
||||
@@ -117,6 +117,6 @@ describe("runSummarizeRetryStrategy", () => {
|
||||
//#then
|
||||
expect(timeoutCalls.length).toBe(1)
|
||||
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
|
||||
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(500)
|
||||
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(300)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,7 @@ export async function runSummarizeRetryStrategy(params: {
|
||||
body: summarizeBody as never,
|
||||
query: { directory: params.directory },
|
||||
})
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
return
|
||||
} catch {
|
||||
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { log, normalizeModelID } from "../../shared"
|
||||
|
||||
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
|
||||
const OPUS_PATTERN = /claude-.*opus/i
|
||||
|
||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
||||
@@ -8,9 +8,9 @@ function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function isOpus46(modelID: string): boolean {
|
||||
function isOpusModel(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return OPUS_4_6_PATTERN.test(normalized)
|
||||
return OPUS_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
interface ChatParamsInput {
|
||||
@@ -28,6 +28,20 @@ interface ChatParamsOutput {
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid thinking budget levels per model tier.
|
||||
* Opus supports "max"; all other Claude models cap at "high".
|
||||
*/
|
||||
const MAX_VARIANT_BY_TIER: Record<string, string> = {
|
||||
opus: "max",
|
||||
default: "high",
|
||||
}
|
||||
|
||||
function clampVariant(variant: string, isOpus: boolean): string {
|
||||
if (variant !== "max") return variant
|
||||
return isOpus ? MAX_VARIANT_BY_TIER.opus : MAX_VARIANT_BY_TIER.default
|
||||
}
|
||||
|
||||
export function createAnthropicEffortHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
@@ -38,15 +52,27 @@ export function createAnthropicEffortHook() {
|
||||
if (!model?.modelID || !model?.providerID) return
|
||||
if (message.variant !== "max") return
|
||||
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
||||
if (!isOpus46(model.modelID)) return
|
||||
if (output.options.effort !== undefined) return
|
||||
|
||||
output.options.effort = "max"
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
const opus = isOpusModel(model.modelID)
|
||||
const clamped = clampVariant(message.variant, opus)
|
||||
output.options.effort = clamped
|
||||
|
||||
if (!opus) {
|
||||
// Override the variant so OpenCode doesn't pass "max" to the API
|
||||
;(message as { variant?: string }).variant = clamped
|
||||
log("anthropic-effort: clamped variant max→high for non-Opus model", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
} else {
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,186 +45,99 @@ function createMockParams(overrides: {
|
||||
}
|
||||
|
||||
describe("createAnthropicEffortHook", () => {
|
||||
describe("opus 4-6 with variant max", () => {
|
||||
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
|
||||
//#given anthropic opus-4-6 model with variant max
|
||||
describe("opus family with variant max", () => {
|
||||
it("injects effort max for anthropic opus-4-6", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected into options
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
|
||||
//#given github-copilot provider with claude-opus-4-6
|
||||
it("injects effort max for another opus family model such as opus-4-5", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ modelID: "claude-opus-4-5" })
|
||||
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("injects effort max for dotted opus ids", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ modelID: "claude-opus-4.6" })
|
||||
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should preserve max for other opus model IDs such as opus-4-5", async () => {
|
||||
//#given another opus model id that is not 4.6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "github-copilot",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-5",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected (github-copilot resolves to anthropic)
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
|
||||
//#given opencode provider with claude-opus-4-6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "opencode",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for google-vertex-anthropic provider", async () => {
|
||||
//#given google-vertex-anthropic provider with claude-opus-4-6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "google-vertex-anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should handle normalized model ID with dots (opus-4.6)", async () => {
|
||||
//#given model ID with dots instead of hyphens
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
modelID: "claude-opus-4.6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then should normalize and inject effort
|
||||
//#then max should still be treated as valid for opus family
|
||||
expect(output.options.effort).toBe("max")
|
||||
expect(input.message.variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
describe("conditions NOT met - should skip", () => {
|
||||
it("should NOT inject effort when variant is not max", async () => {
|
||||
//#given opus-4-6 with variant high (not max)
|
||||
describe("skip conditions", () => {
|
||||
it("does nothing when variant is not max", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ variant: "high" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject effort when variant is undefined", async () => {
|
||||
//#given opus-4-6 with no variant
|
||||
it("does nothing when variant is undefined", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ variant: undefined })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject effort for non-opus model", async () => {
|
||||
//#given claude-sonnet-4-6 (not opus)
|
||||
it("should clamp effort to high for non-opus claude model with variant max", async () => {
|
||||
//#given claude-sonnet-4-6 (not opus) with variant max
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
modelID: "claude-sonnet-4-6",
|
||||
})
|
||||
const { input, output } = createMockParams({ modelID: "claude-sonnet-4-6" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
//#then effort should be clamped to high (not max)
|
||||
expect(output.options.effort).toBe("high")
|
||||
expect(input.message.variant).toBe("high")
|
||||
})
|
||||
|
||||
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
|
||||
//#given openai provider with gpt model
|
||||
it("does nothing for non-claude providers/models", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
const { input, output } = createMockParams({ providerID: "openai", modelID: "gpt-5.4" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT throw when model.modelID is undefined", async () => {
|
||||
//#given model with undefined modelID (runtime edge case)
|
||||
const hook = createAnthropicEffortHook()
|
||||
const input = {
|
||||
sessionID: "test-session",
|
||||
agent: { name: "sisyphus" },
|
||||
model: { providerID: "anthropic", modelID: undefined as unknown as string },
|
||||
provider: { id: "anthropic" },
|
||||
message: { variant: "max" as const },
|
||||
}
|
||||
const output = { temperature: 0.1, options: {} }
|
||||
|
||||
//#when chat.params hook is called with undefined modelID
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then should gracefully skip without throwing
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("preserves existing options", () => {
|
||||
it("should NOT overwrite existing effort if already set", async () => {
|
||||
//#given options already have effort set
|
||||
describe("existing options", () => {
|
||||
it("does not overwrite existing effort", async () => {
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
existingOptions: { effort: "high" },
|
||||
})
|
||||
const { input, output } = createMockParams({ existingOptions: { effort: "high" } })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then existing effort should be preserved
|
||||
expect(output.options.effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should preserve other existing options when injecting effort", async () => {
|
||||
//#given options with existing thinking config
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
existingOptions: {
|
||||
thinking: { type: "enabled", budgetTokens: 31999 },
|
||||
},
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be added without affecting thinking
|
||||
expect(output.options.effort).toBe("max")
|
||||
expect(output.options.thinking).toEqual({
|
||||
type: "enabled",
|
||||
budgetTokens: 31999,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { _resetForTesting, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
let testDirectory = ""
|
||||
let promptCalls: Array<unknown> = []
|
||||
|
||||
function writeIncompleteBoulder(): void {
|
||||
function writeIncompleteBoulder(overrides: Partial<BoulderState> = {}): void {
|
||||
const planPath = join(testDirectory, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
...overrides,
|
||||
}
|
||||
|
||||
writeBoulderState(testDirectory, state)
|
||||
@@ -103,6 +104,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
|
||||
writeIncompleteBoulder()
|
||||
subagentSessions.add(subagentSessionID)
|
||||
setSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: intermediateParentSessionID,
|
||||
@@ -119,4 +121,63 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
|
||||
it("does not inject continuation for boulder-lineage subagent with non-matching agent", async () => {
|
||||
const subagentSessionID = "subagent-session-agent-mismatch"
|
||||
|
||||
writeIncompleteBoulder({ agent: "atlas" })
|
||||
subagentSessions.add(subagentSessionID)
|
||||
setSessionAgent(subagentSessionID, "sisyphus-junior")
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||
assert.equal(promptCalls.length, 0)
|
||||
})
|
||||
|
||||
it("injects continuation for boulder-lineage subagent with matching agent", async () => {
|
||||
const subagentSessionID = "subagent-session-agent-match"
|
||||
|
||||
writeIncompleteBoulder({ agent: "atlas" })
|
||||
subagentSessions.add(subagentSessionID)
|
||||
setSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
|
||||
it("injects continuation for explicitly tracked boulder session regardless of agent", async () => {
|
||||
writeIncompleteBoulder({ agent: "atlas" })
|
||||
setSessionAgent(MAIN_SESSION_ID, "hephaestus")
|
||||
|
||||
const hook = createHook()
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
@@ -136,6 +138,23 @@ export async function handleAtlasSessionIdle(input: {
|
||||
})
|
||||
}
|
||||
|
||||
if (subagentSessions.has(sessionID)) {
|
||||
const sessionAgent = getSessionAgent(sessionID)
|
||||
const agentKey = getAgentConfigKey(sessionAgent ?? "")
|
||||
const requiredAgentKey = getAgentConfigKey(boulderState.agent ?? "atlas")
|
||||
const agentMatches =
|
||||
agentKey === requiredAgentKey ||
|
||||
(requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus"))
|
||||
if (!agentMatches) {
|
||||
log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, {
|
||||
sessionID,
|
||||
agent: sessionAgent ?? "unknown",
|
||||
requiredAgent: boulderState.agent ?? "atlas",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
|
||||
@@ -1282,6 +1282,7 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
subagentSessions.add(subagentSessionID)
|
||||
updateSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
const { describe, expect, mock, test } = require("bun:test")
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
mock.module("../../shared/opencode-message-dir", () => ({
|
||||
getMessageDir: () => null,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => true,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/normalize-sdk-response", () => ({
|
||||
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback,
|
||||
}))
|
||||
|
||||
|
||||
@@ -218,21 +218,31 @@ ${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
|
||||
|
||||
**STOP. READ THIS BEFORE PROCEEDING.**
|
||||
|
||||
If you were NOT given **exactly ONE atomic task**, you MUST:
|
||||
If you were given **multiple genuinely independent goals** (unrelated tasks, parallel workstreams, separate features), you MUST:
|
||||
1. **IMMEDIATELY REFUSE** this request
|
||||
2. **DEMAND** the orchestrator provide a single, specific task
|
||||
2. **DEMAND** the orchestrator provide a single goal
|
||||
|
||||
**Your response if multiple tasks detected:**
|
||||
> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality.
|
||||
**What counts as multiple independent tasks (REFUSE):**
|
||||
- "Implement feature A. Also, add feature B."
|
||||
- "Fix bug X. Then refactor module Y. Also update the docs."
|
||||
- Multiple unrelated changes bundled into one request
|
||||
|
||||
**What is a single task with sequential steps (PROCEED):**
|
||||
- A single goal broken into numbered steps (e.g., "Implement X by: 1. finding files, 2. adding logic, 3. writing tests")
|
||||
- Multi-step context where all steps serve ONE objective
|
||||
- Orchestrator-provided context explaining approach for a single deliverable
|
||||
|
||||
**Your response if genuinely independent tasks are detected:**
|
||||
> "I refuse to proceed. You provided multiple independent tasks. Each task needs full attention.
|
||||
>
|
||||
> PROVIDE EXACTLY ONE TASK. One file. One change. One verification.
|
||||
> PROVIDE EXACTLY ONE GOAL. One deliverable. One clear outcome.
|
||||
>
|
||||
> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context."
|
||||
> Batching unrelated tasks causes: incomplete work, missed edge cases, broken tests, wasted context."
|
||||
|
||||
**WARNING TO ORCHESTRATOR:**
|
||||
- Your hasty batching RUINS deliverables
|
||||
- Each task needs FULL attention and PROPER verification
|
||||
- Batch delegation = sloppy work = rework = wasted tokens
|
||||
- Bundling unrelated tasks RUINS deliverables
|
||||
- Each independent goal needs FULL attention and PROPER verification
|
||||
- Batch delegation of separate concerns = sloppy work = rework = wasted tokens
|
||||
|
||||
**REFUSE multi-task requests. DEMAND single-task clarity.**
|
||||
**REFUSE genuinely multi-task requests. ALLOW single-goal multi-step workflows.**
|
||||
`
|
||||
|
||||
@@ -120,11 +120,13 @@ export function createToolExecuteAfterHandler(input: {
|
||||
}
|
||||
|
||||
if (toolOutput.output && typeof toolOutput.output === "string") {
|
||||
const gitStats = collectGitDiffStats(ctx.directory)
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const worktreePath = boulderState?.worktree_path?.trim()
|
||||
const verificationDirectory = worktreePath ? worktreePath : ctx.directory
|
||||
const gitStats = collectGitDiffStats(verificationDirectory)
|
||||
const fileChanges = formatFileChanges(gitStats)
|
||||
const extractedSessionId = extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
const mockShowConfigErrorsIfAny = mock(async () => {})
|
||||
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
||||
const mockUpdateAndShowConnectedProvidersCacheStatus = mock(async () => {})
|
||||
const mockRefreshModelCapabilitiesOnStartup = mock(async () => {})
|
||||
const mockShowLocalDevToast = mock(async () => {})
|
||||
const mockShowVersionToast = mock(async () => {})
|
||||
const mockRunBackgroundUpdateCheck = mock(async () => {})
|
||||
@@ -22,6 +23,10 @@ mock.module("./hook/connected-providers-status", () => ({
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus,
|
||||
}))
|
||||
|
||||
mock.module("./hook/model-capabilities-status", () => ({
|
||||
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
|
||||
}))
|
||||
|
||||
mock.module("./hook/startup-toasts", () => ({
|
||||
showLocalDevToast: mockShowLocalDevToast,
|
||||
showVersionToast: mockShowVersionToast,
|
||||
@@ -78,6 +83,7 @@ beforeEach(() => {
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
|
||||
mockRefreshModelCapabilitiesOnStartup.mockClear()
|
||||
mockShowLocalDevToast.mockClear()
|
||||
mockShowVersionToast.mockClear()
|
||||
mockRunBackgroundUpdateCheck.mockClear()
|
||||
@@ -112,6 +118,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
|
||||
@@ -129,6 +136,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - startup checks, toast, and background check run
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
|
||||
@@ -146,6 +154,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
@@ -165,6 +174,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - side effects execute only once
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
|
||||
@@ -183,6 +193,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - local dev toast is shown and background check is skipped
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
@@ -205,6 +216,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
|
||||
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
|
||||
expect(mockShowVersionToast).not.toHaveBeenCalled()
|
||||
|
||||
@@ -5,11 +5,17 @@ import type { AutoUpdateCheckerOptions } from "./types"
|
||||
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
|
||||
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
|
||||
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
|
||||
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
|
||||
import { showModelCacheWarningIfNeeded } from "./hook/model-cache-warning"
|
||||
import { showLocalDevToast, showVersionToast } from "./hook/startup-toasts"
|
||||
|
||||
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
|
||||
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
|
||||
const {
|
||||
showStartupToast = true,
|
||||
isSisyphusEnabled = false,
|
||||
autoUpdate = true,
|
||||
modelCapabilities,
|
||||
} = options
|
||||
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"
|
||||
|
||||
const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => {
|
||||
@@ -43,6 +49,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
|
||||
|
||||
await showConfigErrorsIfAny(ctx)
|
||||
await updateAndShowConnectedProvidersCacheStatus(ctx)
|
||||
await refreshModelCapabilitiesOnStartup(modelCapabilities)
|
||||
await showModelCacheWarningIfNeeded(ctx)
|
||||
|
||||
if (localDevVersion) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ModelCapabilitiesConfig } from "../../../config/schema/model-capabilities"
|
||||
import { refreshModelCapabilitiesCache } from "../../../shared/model-capabilities-cache"
|
||||
import { log } from "../../../shared/logger"
|
||||
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 5000
|
||||
|
||||
export async function refreshModelCapabilitiesOnStartup(
|
||||
config: ModelCapabilitiesConfig | undefined,
|
||||
): Promise<void> {
|
||||
if (config?.enabled === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config?.auto_refresh_on_start === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutMs = config?.refresh_timeout_ms ?? DEFAULT_REFRESH_TIMEOUT_MS
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
refreshModelCapabilitiesCache({
|
||||
sourceUrl: config?.source_url,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error("Model capabilities refresh timed out")), timeoutMs)
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
log("[auto-update-checker] Model capabilities refresh failed", { error: String(error) })
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ModelCapabilitiesConfig } from "../../config/schema/model-capabilities"
|
||||
|
||||
export interface NpmDistTags {
|
||||
latest: string
|
||||
[key: string]: string
|
||||
@@ -26,4 +28,5 @@ export interface AutoUpdateCheckerOptions {
|
||||
showStartupToast?: boolean
|
||||
isSisyphusEnabled?: boolean
|
||||
autoUpdate?: boolean
|
||||
modelCapabilities?: ModelCapabilitiesConfig
|
||||
}
|
||||
|
||||
@@ -52,3 +52,4 @@ export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||
export { createReadImageResizerHook } from "./read-image-resizer"
|
||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
||||
|
||||
@@ -2,7 +2,7 @@ export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
// Re-export from submodules
|
||||
export { isPlannerAgent, getUltraworkMessage } from "./ultrawork"
|
||||
export { isPlannerAgent, isNonOmoAgent, getUltraworkMessage } from "./ultrawork"
|
||||
export { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
|
||||
export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { isPlannerAgent } from "./constants"
|
||||
import { isPlannerAgent, isNonOmoAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
isSystemDirective,
|
||||
@@ -45,6 +45,12 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
|
||||
|
||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||
|
||||
// Skip all keyword injection for non-OMO agents (e.g., OpenCode-Builder, Plan)
|
||||
if (isNonOmoAgent(currentAgent)) {
|
||||
log(`[keyword-detector] Skipping keyword injection for non-OMO agent`, { sessionID: input.sessionID, agent: currentAgent })
|
||||
return
|
||||
}
|
||||
|
||||
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
|
||||
const cleanText = removeSystemReminders(promptText)
|
||||
const modelID = input.model?.modelID
|
||||
|
||||
@@ -746,3 +746,109 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector non-OMO agent skipping", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should skip all keyword injection for OpenCode-Builder agent", async () => {
|
||||
// given - keyword-detector hook with Builder agent
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "builder-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork search and analyze this code" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with OpenCode-Builder agent
|
||||
await hook["chat.message"]({ sessionID, agent: "OpenCode-Builder" }, output)
|
||||
|
||||
// then - no keywords should be injected
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("ultrawork search and analyze this code")
|
||||
})
|
||||
|
||||
test("should skip all keyword injection for Plan agent", async () => {
|
||||
// given - keyword-detector hook with Plan agent
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "plan-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode analyze mode ultrawork" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with Plan agent
|
||||
await hook["chat.message"]({ sessionID, agent: "Plan" }, output)
|
||||
|
||||
// then - no keywords should be injected for non-OMO Plan agent
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search mode analyze mode ultrawork")
|
||||
})
|
||||
|
||||
test("should still inject keywords for OMO agents like Sisyphus", async () => {
|
||||
// given - keyword-detector hook with Sisyphus agent
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "sisyphus-session-omo"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork implement this" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with Sisyphus (OMO agent)
|
||||
await hook["chat.message"]({ sessionID, agent: "sisyphus" }, output)
|
||||
|
||||
// then - keywords should be injected normally
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
expect(textPart!.text).toContain("implement this")
|
||||
})
|
||||
|
||||
test("should skip keyword injection for agent names containing 'builder'", async () => {
|
||||
// given - keyword-detector hook with a builder-variant agent name
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "custom-builder-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search this codebase" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with a builder-type agent
|
||||
await hook["chat.message"]({ sessionID, agent: "Custom-Builder" }, output)
|
||||
|
||||
// then - search-mode should NOT be injected
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search this codebase")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,8 +293,6 @@ NOW.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
export function getDefaultUltraworkMessage(): string {
|
||||
|
||||
@@ -283,8 +283,6 @@ NOW.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
export function getGeminiUltraworkMessage(): string {
|
||||
|
||||
@@ -166,8 +166,6 @@ A task is complete when:
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
export function getGptUltraworkMessage(): string {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
export {
|
||||
isPlannerAgent,
|
||||
isNonOmoAgent,
|
||||
isGptModel,
|
||||
isGeminiModel,
|
||||
getUltraworkSource,
|
||||
|
||||
@@ -136,7 +136,5 @@ ${ULTRAWORK_PLANNER_SECTION}
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ export function isPlannerAgent(agentName?: string): boolean {
|
||||
return /\bplan\b/.test(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if agent is a non-OMO agent (e.g., OpenCode's built-in Builder/Plan).
|
||||
* Non-OMO agents should not receive keyword injection (search-mode, analyze-mode, etc.).
|
||||
*/
|
||||
export function isNonOmoAgent(agentName?: string): boolean {
|
||||
if (!agentName) return false
|
||||
const lowerName = agentName.toLowerCase()
|
||||
return lowerName.includes("builder") || lowerName === "plan"
|
||||
}
|
||||
|
||||
export { isGptModel, isGeminiModel }
|
||||
|
||||
/** Ultrawork message source type */
|
||||
|
||||
@@ -255,6 +255,50 @@ describe("model fallback hook", () => {
|
||||
clearPendingModelFallback(sessionID)
|
||||
})
|
||||
|
||||
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_model_fallback_preferred_provider"
|
||||
clearPendingModelFallback(sessionID)
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
||||
|
||||
const hook = createModelFallbackHook() as unknown as {
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
setSessionFallbackChain(sessionID, [
|
||||
{ providers: ["provider-y"], model: "fallback-model" },
|
||||
])
|
||||
|
||||
expect(
|
||||
setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"provider-x",
|
||||
"current-model",
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
model: { providerID: "provider-x", modelID: "current-model" },
|
||||
},
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["chat.message"]?.({ sessionID }, output)
|
||||
|
||||
//#then
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "provider-x",
|
||||
modelID: "fallback-model",
|
||||
})
|
||||
clearPendingModelFallback(sessionID)
|
||||
})
|
||||
|
||||
test("shows toast when fallback is applied", async () => {
|
||||
//#given
|
||||
const toastCalls: Array<{ title: string; message: string }> = []
|
||||
@@ -337,7 +381,7 @@ describe("model fallback hook", () => {
|
||||
clearPendingModelFallback(sessionID)
|
||||
})
|
||||
|
||||
test("transforms model names for google provider via fallback chain", async () => {
|
||||
test("preserves canonical google preview model names via fallback chain", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_model_fallback_google"
|
||||
clearPendingModelFallback(sessionID)
|
||||
@@ -351,20 +395,20 @@ describe("model fallback hook", () => {
|
||||
|
||||
// Set a custom fallback chain that routes through google
|
||||
setSessionFallbackChain(sessionID, [
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
|
||||
])
|
||||
|
||||
const set = setPendingModelFallback(
|
||||
sessionID,
|
||||
"Oracle",
|
||||
"google",
|
||||
"gemini-3-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
)
|
||||
expect(set).toBe(true)
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
model: { providerID: "google", modelID: "gemini-3-pro" },
|
||||
model: { providerID: "google", modelID: "gemini-3.1-pro-preview" },
|
||||
},
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
}
|
||||
@@ -372,10 +416,10 @@ describe("model fallback hook", () => {
|
||||
//#when
|
||||
await hook["chat.message"]?.({ sessionID }, output)
|
||||
|
||||
//#then — model name should remain gemini-3-pro because no google transform exists for this ID
|
||||
//#then: model name should remain gemini-3.1-pro-preview because no google transform exists for this ID
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "google",
|
||||
modelID: "gemini-3-pro",
|
||||
modelID: "gemini-3.1-pro-preview",
|
||||
})
|
||||
|
||||
clearPendingModelFallback(sessionID)
|
||||
|
||||
@@ -130,14 +130,21 @@ export function getNextFallback(
|
||||
|
||||
const providerModelsCache = readProviderModelsCache()
|
||||
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
const connectedSet = connectedProviders
|
||||
? new Set(connectedProviders.map((provider) => provider.toLowerCase()))
|
||||
: null
|
||||
|
||||
const isReachable = (entry: FallbackEntry): boolean => {
|
||||
if (!connectedSet) return true
|
||||
|
||||
// Gate only on provider connectivity. Provider model lists can be stale/incomplete,
|
||||
// especially after users manually add models to opencode.json.
|
||||
return entry.providers.some((p) => connectedSet.has(p))
|
||||
if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const preferredProvider = state.providerID.toLowerCase()
|
||||
return connectedSet.has(preferredProvider)
|
||||
}
|
||||
|
||||
while (state.attemptCount < fallbackChain.length) {
|
||||
|
||||
@@ -52,8 +52,6 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
// The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected
|
||||
// for git commands to prevent interactive prompts.
|
||||
|
||||
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
|
||||
// (via Git Bash, WSL, etc.), so always use unix export syntax.
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix")
|
||||
|
||||
// Check if the command already starts with the prefix to avoid stacking.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { log } from "../shared/logger"
|
||||
import { resolveNoTextTailFromSession } from "./preemptive-compaction-no-text-tail"
|
||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||
|
||||
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
||||
const POST_COMPACTION_MONITOR_COUNT = 5
|
||||
const POST_COMPACTION_NO_TEXT_THRESHOLD = 3
|
||||
|
||||
declare function setTimeout(handler: () => void, timeout?: number): unknown
|
||||
declare function clearTimeout(timeoutID: unknown): void
|
||||
|
||||
interface CompactionTargetState {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
interface ClientLike {
|
||||
session: {
|
||||
summarize: (input: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
messages: (input: {
|
||||
path: { id: string }
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
showToast: (input: {
|
||||
body: {
|
||||
title: string
|
||||
message: string
|
||||
variant: "warning"
|
||||
duration: number
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface AssistantCompactionMessageInfo {
|
||||
sessionID: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
async function withTimeout<TValue>(
|
||||
promise: Promise<TValue>,
|
||||
timeoutMs: number,
|
||||
errorMessage: string,
|
||||
): Promise<TValue> {
|
||||
let timeoutID: unknown
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
reject(new Error(errorMessage))
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return await Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timeoutID)
|
||||
})
|
||||
}
|
||||
|
||||
export function createPostCompactionDegradationMonitor(args: {
|
||||
client: ClientLike
|
||||
directory: string
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
tokenCache: Map<string, CompactionTargetState>
|
||||
compactionInProgress: Set<string>
|
||||
}) {
|
||||
const { client, directory, pluginConfig, tokenCache, compactionInProgress } = args
|
||||
const postCompactionRemaining = new Map<string, number>()
|
||||
const postCompactionNoTextStreak = new Map<string, number>()
|
||||
const postCompactionRecoveryTriggered = new Set<string>()
|
||||
const postCompactionEpoch = new Map<string, number>()
|
||||
|
||||
const clear = (sessionID: string): void => {
|
||||
postCompactionRemaining.delete(sessionID)
|
||||
postCompactionNoTextStreak.delete(sessionID)
|
||||
postCompactionRecoveryTriggered.delete(sessionID)
|
||||
postCompactionEpoch.delete(sessionID)
|
||||
}
|
||||
|
||||
const onSessionCompacted = (sessionID: string): void => {
|
||||
const nextEpoch = (postCompactionEpoch.get(sessionID) ?? 0) + 1
|
||||
postCompactionEpoch.set(sessionID, nextEpoch)
|
||||
postCompactionRemaining.set(sessionID, POST_COMPACTION_MONITOR_COUNT)
|
||||
postCompactionNoTextStreak.set(sessionID, 0)
|
||||
postCompactionRecoveryTriggered.delete(sessionID)
|
||||
}
|
||||
|
||||
const triggerRecovery = async (sessionID: string): Promise<void> => {
|
||||
if (postCompactionRecoveryTriggered.has(sessionID) || compactionInProgress.has(sessionID)) return
|
||||
|
||||
const cached = tokenCache.get(sessionID)
|
||||
if (!cached?.modelID) {
|
||||
log("[preemptive-compaction] No-text tail detected but compaction model is unavailable", { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
postCompactionRecoveryTriggered.add(sessionID)
|
||||
compactionInProgress.add(sessionID)
|
||||
const recoveryEpoch = postCompactionEpoch.get(sessionID) ?? 0
|
||||
|
||||
try {
|
||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||
pluginConfig,
|
||||
sessionID,
|
||||
cached.providerID,
|
||||
cached.modelID,
|
||||
)
|
||||
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Session Degradation Detected",
|
||||
message: "Detected repeated no-text assistant responses after compaction. Retrying compaction recovery.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await withTimeout(
|
||||
client.session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: targetProviderID, modelID: targetModelID },
|
||||
query: { directory },
|
||||
}),
|
||||
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
|
||||
`Compaction recovery summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
|
||||
)
|
||||
|
||||
log("[preemptive-compaction] Triggered recovery after post-compaction no-text tail", { sessionID })
|
||||
} catch (error) {
|
||||
log("[preemptive-compaction] Failed to recover post-compaction no-text tail", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
} finally {
|
||||
compactionInProgress.delete(sessionID)
|
||||
if ((postCompactionEpoch.get(sessionID) ?? 0) === recoveryEpoch) {
|
||||
clear(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onAssistantMessageUpdated = async (info: AssistantCompactionMessageInfo): Promise<void> => {
|
||||
const remaining = postCompactionRemaining.get(info.sessionID)
|
||||
if (!remaining || remaining <= 0) return
|
||||
|
||||
if (remaining === 1) {
|
||||
postCompactionRemaining.delete(info.sessionID)
|
||||
} else {
|
||||
postCompactionRemaining.set(info.sessionID, remaining - 1)
|
||||
}
|
||||
|
||||
const isNoTextTail = await resolveNoTextTailFromSession({
|
||||
client,
|
||||
sessionID: info.sessionID,
|
||||
messageID: info.id,
|
||||
directory,
|
||||
})
|
||||
|
||||
if (!isNoTextTail) {
|
||||
postCompactionNoTextStreak.set(info.sessionID, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextStreak = (postCompactionNoTextStreak.get(info.sessionID) ?? 0) + 1
|
||||
postCompactionNoTextStreak.set(info.sessionID, nextStreak)
|
||||
|
||||
if (nextStreak >= POST_COMPACTION_NO_TEXT_THRESHOLD) {
|
||||
log("[preemptive-compaction] Detected post-compaction no-text tail pattern", {
|
||||
sessionID: info.sessionID,
|
||||
streak: nextStreak,
|
||||
})
|
||||
await triggerRecovery(info.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clear,
|
||||
onSessionCompacted,
|
||||
onAssistantMessageUpdated,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { normalizeSDKResponse } from "../shared/normalize-sdk-response"
|
||||
|
||||
const STEP_ONLY_TYPES = new Set(["step-start", "step-finish"])
|
||||
|
||||
interface MessagePart {
|
||||
type?: unknown
|
||||
text?: unknown
|
||||
}
|
||||
|
||||
interface SessionMessage {
|
||||
info?: {
|
||||
id?: string
|
||||
role?: string
|
||||
}
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
export function isStepOnlyNoTextParts(parts: unknown): boolean {
|
||||
if (!Array.isArray(parts) || parts.length === 0) return false
|
||||
|
||||
return parts.every((part) => {
|
||||
const type = (part as MessagePart | undefined)?.type
|
||||
if (typeof type !== "string") return false
|
||||
if (!STEP_ONLY_TYPES.has(type)) return false
|
||||
|
||||
const text = (part as MessagePart | undefined)?.text
|
||||
if (typeof text === "string" && text.trim().length > 0) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function findMessageByID(messages: SessionMessage[], messageID?: string): SessionMessage | undefined {
|
||||
if (!messageID) return undefined
|
||||
return messages.find((message) => message.info?.id === messageID)
|
||||
}
|
||||
|
||||
export async function resolveNoTextTailFromSession(args: {
|
||||
client: {
|
||||
session: {
|
||||
messages: (input: {
|
||||
path: { id: string }
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
sessionID: string
|
||||
messageID?: string
|
||||
directory: string
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, messageID, directory } = args
|
||||
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
if (!Array.isArray(messages) || messages.length === 0) return false
|
||||
|
||||
const target = findMessageByID(messages, messageID) ?? messages[messages.length - 1]
|
||||
if (target.info?.role !== "assistant") return false
|
||||
|
||||
return isStepOnlyNoTextParts(target.parts)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
const logMock = mock(() => {})
|
||||
|
||||
mock.module("../shared/logger", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
|
||||
const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
|
||||
|
||||
type AssistantHistoryMessage = {
|
||||
info: {
|
||||
id: string
|
||||
role: "assistant"
|
||||
}
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function createMockCtx(sessionHistory: AssistantHistoryMessage[]) {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: sessionHistory })),
|
||||
summarize: mock(() => Promise.resolve({})),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve({})),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
function appendAssistantHistory(
|
||||
sessionHistory: AssistantHistoryMessage[],
|
||||
input: {
|
||||
id: string
|
||||
parts: AssistantHistoryMessage["parts"]
|
||||
},
|
||||
): void {
|
||||
sessionHistory.push({
|
||||
info: {
|
||||
id: input.id,
|
||||
role: "assistant",
|
||||
},
|
||||
parts: input.parts,
|
||||
})
|
||||
}
|
||||
|
||||
function buildAssistantUpdate(input: {
|
||||
sessionID: string
|
||||
id: string
|
||||
parts: unknown[]
|
||||
}): {
|
||||
event: {
|
||||
type: string
|
||||
properties: {
|
||||
info: {
|
||||
id: string
|
||||
role: string
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
finish: boolean
|
||||
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
parts: unknown[]
|
||||
}
|
||||
}
|
||||
}
|
||||
} {
|
||||
return {
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: input.id,
|
||||
role: "assistant",
|
||||
sessionID: input.sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: { input: 1000, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
parts: input.parts,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("preemptive-compaction post-compaction degradation monitor", () => {
|
||||
beforeEach(() => {
|
||||
logMock.mockClear()
|
||||
})
|
||||
|
||||
it("triggers recovery summarize after three consecutive no-text tail messages", async () => {
|
||||
// given
|
||||
const sessionHistory: AssistantHistoryMessage[] = []
|
||||
const ctx = createMockCtx(sessionHistory)
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||
const sessionID = "ses_tail_recovery"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }]
|
||||
|
||||
// when
|
||||
appendAssistantHistory(sessionHistory, { id: "msg_1", parts: stepOnlyParts })
|
||||
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts }))
|
||||
|
||||
appendAssistantHistory(sessionHistory, { id: "msg_2", parts: stepOnlyParts })
|
||||
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts }))
|
||||
|
||||
appendAssistantHistory(sessionHistory, { id: "msg_3", parts: stepOnlyParts })
|
||||
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_3", parts: stepOnlyParts }))
|
||||
|
||||
// then
|
||||
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.client.tui.showToast).toHaveBeenCalledTimes(1)
|
||||
expect(logMock).toHaveBeenCalledWith(
|
||||
"[preemptive-compaction] Detected post-compaction no-text tail pattern",
|
||||
{
|
||||
sessionID,
|
||||
streak: 3,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("resets no-text streak when assistant emits text content", async () => {
|
||||
// given
|
||||
const sessionHistory: AssistantHistoryMessage[] = []
|
||||
const ctx = createMockCtx(sessionHistory)
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||
const sessionID = "ses_tail_reset"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
appendAssistantHistory(sessionHistory, {
|
||||
id: "msg_1",
|
||||
parts: [{ type: "step-start" }, { type: "step-finish" }],
|
||||
})
|
||||
await hook.event(buildAssistantUpdate({
|
||||
sessionID,
|
||||
id: "msg_1",
|
||||
parts: [{ type: "step-start" }, { type: "step-finish" }],
|
||||
}))
|
||||
|
||||
appendAssistantHistory(sessionHistory, {
|
||||
id: "msg_2",
|
||||
parts: [{ type: "text", text: "Recovered response" }],
|
||||
})
|
||||
await hook.event(buildAssistantUpdate({
|
||||
sessionID,
|
||||
id: "msg_2",
|
||||
parts: [{ type: "text", text: "Recovered response" }],
|
||||
}))
|
||||
|
||||
appendAssistantHistory(sessionHistory, {
|
||||
id: "msg_3",
|
||||
parts: [{ type: "step-start" }, { type: "step-finish" }],
|
||||
})
|
||||
await hook.event(buildAssistantUpdate({
|
||||
sessionID,
|
||||
id: "msg_3",
|
||||
parts: [{ type: "step-start" }, { type: "step-finish" }],
|
||||
}))
|
||||
|
||||
appendAssistantHistory(sessionHistory, {
|
||||
id: "msg_4",
|
||||
parts: [{ type: "step-start" }, { type: "step-finish" }],
|
||||
})
|
||||
await hook.event(buildAssistantUpdate({
|
||||
sessionID,
|
||||
id: "msg_4",
|
||||
parts: [{ type: "step-start" }, { type: "step-finish" }],
|
||||
}))
|
||||
|
||||
// then
|
||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
} from "../shared/context-limit-resolver"
|
||||
|
||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||
|
||||
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
||||
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
||||
|
||||
declare function setTimeout(handler: () => void, timeout?: number): unknown
|
||||
declare function clearTimeout(timeoutID: unknown): void
|
||||
|
||||
interface TokenInfo {
|
||||
input: number
|
||||
output: number
|
||||
@@ -28,7 +32,7 @@ async function withTimeout<TValue>(
|
||||
timeoutMs: number,
|
||||
errorMessage: string,
|
||||
): Promise<TValue> {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | undefined
|
||||
let timeoutID: unknown
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
@@ -37,9 +41,7 @@ async function withTimeout<TValue>(
|
||||
})
|
||||
|
||||
return await Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
}
|
||||
clearTimeout(timeoutID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +70,14 @@ export function createPreemptiveCompactionHook(
|
||||
const compactedSessions = new Set<string>()
|
||||
const tokenCache = new Map<string, CachedCompactionState>()
|
||||
|
||||
const postCompactionMonitor = createPostCompactionDegradationMonitor({
|
||||
client: ctx.client,
|
||||
directory: ctx.directory,
|
||||
pluginConfig,
|
||||
tokenCache,
|
||||
compactionInProgress,
|
||||
})
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
_output: { title: string; output: string; metadata: unknown }
|
||||
@@ -92,14 +102,9 @@ export function createPreemptiveCompactionHook(
|
||||
return
|
||||
}
|
||||
|
||||
const lastTokens = cached.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
|
||||
const usageRatio = totalInputTokens / actualLimit
|
||||
|
||||
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return
|
||||
|
||||
const modelID = cached.modelID
|
||||
if (!modelID) return
|
||||
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
|
||||
|
||||
compactionInProgress.add(sessionID)
|
||||
|
||||
@@ -108,7 +113,7 @@ export function createPreemptiveCompactionHook(
|
||||
pluginConfig,
|
||||
sessionID,
|
||||
cached.providerID,
|
||||
modelID
|
||||
cached.modelID,
|
||||
)
|
||||
|
||||
await withTimeout(
|
||||
@@ -133,17 +138,28 @@ export function createPreemptiveCompactionHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
compactionInProgress.delete(sessionInfo.id)
|
||||
compactedSessions.delete(sessionInfo.id)
|
||||
tokenCache.delete(sessionInfo.id)
|
||||
const sessionID = (props?.info as { id?: string } | undefined)?.id
|
||||
if (sessionID) {
|
||||
compactionInProgress.delete(sessionID)
|
||||
compactedSessions.delete(sessionID)
|
||||
tokenCache.delete(sessionID)
|
||||
postCompactionMonitor.clear(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID as string | undefined)
|
||||
?? (props?.info as { id?: string } | undefined)?.id
|
||||
if (sessionID) {
|
||||
postCompactionMonitor.onSessionCompacted(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
providerID?: string
|
||||
@@ -152,15 +168,21 @@ export function createPreemptiveCompactionHook(
|
||||
tokens?: TokenInfo
|
||||
} | undefined
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish) return
|
||||
if (!info.sessionID || !info.providerID || !info.tokens) return
|
||||
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
||||
|
||||
tokenCache.set(info.sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
if (info.providerID && info.tokens) {
|
||||
tokenCache.set(info.sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
}
|
||||
compactedSessions.delete(info.sessionID)
|
||||
|
||||
await postCompactionMonitor.onAssistantMessageUpdated({
|
||||
sessionID: info.sessionID,
|
||||
id: info.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const PLANNING_CONSULT_WARNING = `
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)}
|
||||
|
||||
You are being invoked by ${getAgentDisplayName("prometheus")}, a READ-ONLY planning agent.
|
||||
You are being invoked by ${getAgentDisplayName("prometheus")}, a planning agent restricted to .sisyphus/*.md plan files only.
|
||||
|
||||
**CRITICAL CONSTRAINTS:**
|
||||
- DO NOT modify any files (no Write, Edit, or any file mutations)
|
||||
|
||||
@@ -23,12 +23,12 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
|
||||
const toolName = input.tool
|
||||
|
||||
// Inject read-only warning for task tools called by Prometheus
|
||||
// Inject planning-only warning for task tools called by Prometheus
|
||||
if (TASK_TOOLS.includes(toolName)) {
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
output.args.prompt = PLANNING_CONSULT_WARNING + prompt
|
||||
log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, {
|
||||
log(`[${HOOK_NAME}] Injected planning warning to ${toolName}`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
@@ -54,9 +54,8 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` +
|
||||
`[${HOOK_NAME}] Prometheus is a planning agent. File operations restricted to .sisyphus/*.md plan files only. Use task() to delegate implementation. ` +
|
||||
`Attempted to modify: ${filePath}. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("prometheus-md-only", () => {
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Plan Builder", async () => {
|
||||
@@ -85,7 +85,7 @@ describe("prometheus-md-only", () => {
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Planner", async () => {
|
||||
@@ -104,7 +104,7 @@ describe("prometheus-md-only", () => {
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for uppercase PROMETHEUS", async () => {
|
||||
@@ -123,7 +123,7 @@ describe("prometheus-md-only", () => {
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should not enforce restriction for non-Prometheus agent", async () => {
|
||||
@@ -185,7 +185,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should allow Prometheus to write .md files inside .sisyphus/", async () => {
|
||||
@@ -262,7 +262,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should block Edit tool for non-.md files", async () => {
|
||||
@@ -280,7 +280,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should allow bash commands from Prometheus", async () => {
|
||||
@@ -337,7 +337,7 @@ describe("prometheus-md-only", () => {
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should inject read-only warning when Prometheus calls task", async () => {
|
||||
test("should inject planning warning when Prometheus calls task", async () => {
|
||||
// given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
@@ -357,7 +357,7 @@ describe("prometheus-md-only", () => {
|
||||
expect(output.args.prompt).toContain("DO NOT modify any files")
|
||||
})
|
||||
|
||||
test("should inject read-only warning when Prometheus calls task", async () => {
|
||||
test("should inject planning warning when Prometheus calls task", async () => {
|
||||
// given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
@@ -376,7 +376,7 @@ describe("prometheus-md-only", () => {
|
||||
expect(output.args.prompt).toContain(SYSTEM_DIRECTIVE_PREFIX)
|
||||
})
|
||||
|
||||
test("should inject read-only warning when Prometheus calls call_omo_agent", async () => {
|
||||
test("should inject planning warning when Prometheus calls call_omo_agent", async () => {
|
||||
// given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
@@ -540,7 +540,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / then - should block because boulder says prometheus
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should fall back to message files when session not in boulder", async () => {
|
||||
@@ -573,7 +573,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / then - should block because falls back to message files (prometheus)
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -675,7 +675,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
|
||||
@@ -713,7 +713,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
|
||||
test("should allow case-insensitive .SISYPHUS directory", async () => {
|
||||
@@ -790,7 +790,7 @@ describe("prometheus-md-only", () => {
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
).rejects.toThrow("File operations restricted to .sisyphus/*.md plan files only")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import type { FallbackModelObject } from "../../config/schema/fallback-models"
|
||||
import { agentPattern } from "./agent-resolver"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { normalizeFallbackModels } from "../../shared/model-resolver"
|
||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
|
||||
/**
|
||||
* Returns fallback model strings for the runtime-fallback system.
|
||||
* Object entries are flattened to "provider/model(variant)" strings so the
|
||||
* string-based fallback state machine can work with them unchanged.
|
||||
*/
|
||||
export function getFallbackModelsForSession(
|
||||
sessionID: string,
|
||||
agent: string | undefined,
|
||||
@@ -12,22 +18,45 @@ export function getFallbackModelsForSession(
|
||||
): string[] {
|
||||
if (!pluginConfig) return []
|
||||
|
||||
const raw = getRawFallbackModelsForSession(sessionID, agent, pluginConfig)
|
||||
return flattenToFallbackModelStrings(raw) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw fallback model entries (strings and objects) for a session.
|
||||
* Use this when per-model settings (temperature, reasoningEffort, etc.) must be
|
||||
* preserved — e.g. before passing to buildFallbackChainFromModels.
|
||||
*/
|
||||
export function getRawFallbackModels(
|
||||
sessionID: string,
|
||||
agent: string | undefined,
|
||||
pluginConfig: OhMyOpenCodeConfig | undefined,
|
||||
): (string | FallbackModelObject)[] | undefined {
|
||||
if (!pluginConfig) return undefined
|
||||
return getRawFallbackModelsForSession(sessionID, agent, pluginConfig)
|
||||
}
|
||||
|
||||
function getRawFallbackModelsForSession(
|
||||
sessionID: string,
|
||||
agent: string | undefined,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
): (string | FallbackModelObject)[] | undefined {
|
||||
const sessionCategory = SessionCategoryRegistry.get(sessionID)
|
||||
if (sessionCategory && pluginConfig.categories?.[sessionCategory]) {
|
||||
const categoryConfig = pluginConfig.categories[sessionCategory]
|
||||
if (categoryConfig?.fallback_models) {
|
||||
return normalizeFallbackModels(categoryConfig.fallback_models) ?? []
|
||||
return normalizeFallbackModels(categoryConfig.fallback_models)
|
||||
}
|
||||
}
|
||||
|
||||
const tryGetFallbackFromAgent = (agentName: string): string[] | undefined => {
|
||||
const tryGetFallbackFromAgent = (agentName: string): (string | FallbackModelObject)[] | undefined => {
|
||||
const agentConfig = pluginConfig.agents?.[agentName as keyof typeof pluginConfig.agents]
|
||||
if (!agentConfig) return undefined
|
||||
|
||||
|
||||
if (agentConfig?.fallback_models) {
|
||||
return normalizeFallbackModels(agentConfig.fallback_models)
|
||||
}
|
||||
|
||||
|
||||
const agentCategory = agentConfig?.category
|
||||
if (agentCategory && pluginConfig.categories?.[agentCategory]) {
|
||||
const categoryConfig = pluginConfig.categories[agentCategory]
|
||||
@@ -35,7 +64,7 @@ export function getFallbackModelsForSession(
|
||||
return normalizeFallbackModels(categoryConfig.fallback_models)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -53,5 +82,5 @@ export function getFallbackModelsForSession(
|
||||
|
||||
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
|
||||
|
||||
return []
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HookDeps } from "./types"
|
||||
import type { AutoRetryHelpers } from "./auto-retry"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { HOOK_NAME, RETRYABLE_ERROR_PATTERNS } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { extractAutoRetrySignal } from "./error-classifier"
|
||||
import { createFallbackState } from "./fallback-state"
|
||||
@@ -32,7 +32,14 @@ export function createSessionStatusHandler(
|
||||
|
||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||
const retrySignal = extractAutoRetrySignal({ status: retryMessage, message: retryMessage })
|
||||
if (!retrySignal) return
|
||||
if (!retrySignal) {
|
||||
// Fallback: status.type is already "retry", so check the message against
|
||||
// retryable error patterns directly. This handles providers like Gemini whose
|
||||
// retry status message may not contain "retrying in" text alongside the error.
|
||||
const messageLower = retryMessage.toLowerCase()
|
||||
const matchesRetryablePattern = RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(messageLower))
|
||||
if (!matchesRetryablePattern) return
|
||||
}
|
||||
|
||||
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
|
||||
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { detectErrorType } from "./index"
|
||||
import { prependThinkingPart, prependThinkingPartAsync } from "./storage/thinking-prepend"
|
||||
import { PART_STORAGE } from "../../shared/opencode-storage-paths"
|
||||
|
||||
const { describe, expect, it, mock } = require("bun:test")
|
||||
|
||||
describe("detectErrorType", () => {
|
||||
describe("thinking_block_order errors", () => {
|
||||
@@ -278,3 +283,249 @@ describe("detectErrorType", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type StoredPartRecord = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
signature?: string
|
||||
thinking?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function cleanupParts(messageID: string): void {
|
||||
rmSync(join(PART_STORAGE, messageID), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
describe("thinking-prepend", () => {
|
||||
it("writes the original signed thinking part verbatim for file-backed recovery", () => {
|
||||
const sessionID = "ses_thinking_prepend_sync"
|
||||
const targetMessageID = "msg_target_signed"
|
||||
const originalPart = {
|
||||
id: "prt_prev_signed",
|
||||
sessionID,
|
||||
messageID: "msg_prev_signed",
|
||||
type: "thinking",
|
||||
thinking: "prior reasoning",
|
||||
signature: "sig_prev",
|
||||
} as const satisfies StoredPartRecord
|
||||
|
||||
const result = prependThinkingPart(sessionID, targetMessageID, {
|
||||
isSqliteBackend: () => false,
|
||||
patchPart: async () => true,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => originalPart,
|
||||
findLastThinkingPartFromSDK: async () => null,
|
||||
readTargetPartIDs: () => ["prt_target_text"],
|
||||
readTargetPartIDsFromSDK: async () => [],
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
const writtenPath = join(PART_STORAGE, targetMessageID, `${originalPart.id}.json`)
|
||||
expect(existsSync(writtenPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(writtenPath, "utf-8"))).toEqual(originalPart)
|
||||
|
||||
cleanupParts(targetMessageID)
|
||||
})
|
||||
|
||||
it("returns false without writing when no signed thinking part exists in history", () => {
|
||||
const sessionID = "ses_thinking_prepend_sync_missing"
|
||||
const targetMessageID = "msg_target_missing"
|
||||
|
||||
const result = prependThinkingPart(sessionID, targetMessageID, {
|
||||
isSqliteBackend: () => false,
|
||||
patchPart: async () => true,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => null,
|
||||
findLastThinkingPartFromSDK: async () => null,
|
||||
readTargetPartIDs: () => [],
|
||||
readTargetPartIDsFromSDK: async () => [],
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false)
|
||||
|
||||
cleanupParts(targetMessageID)
|
||||
})
|
||||
|
||||
it("returns false immediately when sqlite backend is active", () => {
|
||||
const result = prependThinkingPart("ses_sqlite", "msg_sqlite", {
|
||||
isSqliteBackend: () => true,
|
||||
patchPart: async () => true,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => null,
|
||||
findLastThinkingPartFromSDK: async () => null,
|
||||
readTargetPartIDs: () => [],
|
||||
readTargetPartIDsFromSDK: async () => [],
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when the reused signed thinking part would not sort before target parts", () => {
|
||||
const sessionID = "ses_thinking_prepend_sync_out_of_order"
|
||||
const targetMessageID = "msg_target_out_of_order"
|
||||
const originalPart = {
|
||||
id: "prt_z_reused",
|
||||
sessionID,
|
||||
messageID: "msg_prev_signed",
|
||||
type: "thinking",
|
||||
thinking: "prior reasoning",
|
||||
signature: "sig_prev",
|
||||
} as const satisfies StoredPartRecord
|
||||
|
||||
const result = prependThinkingPart(sessionID, targetMessageID, {
|
||||
isSqliteBackend: () => false,
|
||||
patchPart: async () => true,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => originalPart,
|
||||
findLastThinkingPartFromSDK: async () => null,
|
||||
readTargetPartIDs: () => ["prt_a_target"],
|
||||
readTargetPartIDsFromSDK: async () => [],
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false)
|
||||
})
|
||||
|
||||
it("patches the original signed thinking part verbatim for sdk-backed recovery", async () => {
|
||||
const prependThinkingPartAsyncUntyped = Reflect.get(
|
||||
{ prependThinkingPartAsync },
|
||||
"prependThinkingPartAsync"
|
||||
)
|
||||
const sessionID = "ses_thinking_prepend_async"
|
||||
const targetMessageID = "msg_target_async"
|
||||
const patchPartMock = mock(async () => true)
|
||||
const originalPart = {
|
||||
id: "prt_prev_async",
|
||||
type: "thinking",
|
||||
thinking: "prior reasoning",
|
||||
signature: "sig_async",
|
||||
} as const
|
||||
const client = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg_prev_async", role: "assistant" },
|
||||
parts: [originalPart],
|
||||
},
|
||||
{
|
||||
info: { id: targetMessageID, role: "assistant" },
|
||||
parts: [{ id: "prt_target_text", type: "text", text: "tool result" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [
|
||||
client,
|
||||
sessionID,
|
||||
targetMessageID,
|
||||
{
|
||||
isSqliteBackend: () => false,
|
||||
patchPart: patchPartMock,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => null,
|
||||
findLastThinkingPartFromSDK: async () => originalPart,
|
||||
readTargetPartIDs: () => [],
|
||||
readTargetPartIDsFromSDK: async () => ["prt_target_text"],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(patchPartMock).toHaveBeenCalledTimes(1)
|
||||
expect(patchPartMock.mock.calls[0]).toEqual([
|
||||
client,
|
||||
sessionID,
|
||||
targetMessageID,
|
||||
"prt_prev_async",
|
||||
originalPart,
|
||||
])
|
||||
})
|
||||
|
||||
it("returns false without patching when sdk history has no signed thinking part", async () => {
|
||||
const prependThinkingPartAsyncUntyped = Reflect.get(
|
||||
{ prependThinkingPartAsync },
|
||||
"prependThinkingPartAsync"
|
||||
)
|
||||
const sessionID = "ses_thinking_prepend_async_missing"
|
||||
const targetMessageID = "msg_target_async_missing"
|
||||
const patchPartMock = mock(async () => true)
|
||||
const client = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg_prev_async", role: "assistant" },
|
||||
parts: [{ id: "prt_prev_reasoning", type: "reasoning", text: "unsigned reasoning" }],
|
||||
},
|
||||
{
|
||||
info: { id: targetMessageID, role: "assistant" },
|
||||
parts: [{ id: "prt_target_text", type: "text", text: "tool result" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [
|
||||
client,
|
||||
sessionID,
|
||||
targetMessageID,
|
||||
{
|
||||
isSqliteBackend: () => false,
|
||||
patchPart: patchPartMock,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => null,
|
||||
findLastThinkingPartFromSDK: async () => null,
|
||||
readTargetPartIDs: () => [],
|
||||
readTargetPartIDsFromSDK: async () => ["prt_target_text"],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(patchPartMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("returns false when the sdk reused signed thinking part would not sort before target parts", async () => {
|
||||
const prependThinkingPartAsyncUntyped = Reflect.get(
|
||||
{ prependThinkingPartAsync },
|
||||
"prependThinkingPartAsync"
|
||||
)
|
||||
const sessionID = "ses_thinking_prepend_async_out_of_order"
|
||||
const targetMessageID = "msg_target_async_out_of_order"
|
||||
const patchPartMock = mock(async () => true)
|
||||
const originalPart = {
|
||||
id: "prt_z_reused",
|
||||
type: "thinking",
|
||||
thinking: "prior reasoning",
|
||||
signature: "sig_async",
|
||||
} as const
|
||||
const client = {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [
|
||||
client,
|
||||
sessionID,
|
||||
targetMessageID,
|
||||
{
|
||||
isSqliteBackend: () => false,
|
||||
patchPart: patchPartMock,
|
||||
log: mock(() => {}),
|
||||
findLastThinkingPart: () => null,
|
||||
findLastThinkingPartFromSDK: async () => originalPart,
|
||||
readTargetPartIDs: () => [],
|
||||
readTargetPartIDsFromSDK: async () => ["prt_a_target"],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(patchPartMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,115 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { PART_STORAGE, THINKING_TYPES } from "../constants"
|
||||
import type { MessageData } from "../types"
|
||||
import type { MessageData, StoredPart } from "../types"
|
||||
import { readMessages } from "./messages-reader"
|
||||
import { readParts } from "./parts-reader"
|
||||
import { log, isSqliteBackend, patchPart } from "../../../shared"
|
||||
import { normalizeSDKResponse } from "../../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
type StoredSignedThinkingPart = StoredPart & {
|
||||
type: "thinking" | "redacted_thinking"
|
||||
signature: string
|
||||
}
|
||||
type SDKMessagePart = NonNullable<MessageData["parts"]>[number]
|
||||
type SDKSignedThinkingPart = SDKMessagePart & {
|
||||
id: string
|
||||
type: "thinking" | "redacted_thinking"
|
||||
signature: string
|
||||
}
|
||||
|
||||
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
||||
type ThinkingPrependDeps = {
|
||||
isSqliteBackend: typeof isSqliteBackend
|
||||
patchPart: typeof patchPart
|
||||
log: typeof log
|
||||
findLastThinkingPart: typeof findLastThinkingPart
|
||||
findLastThinkingPartFromSDK: typeof findLastThinkingPartFromSDK
|
||||
readTargetPartIDs: typeof readTargetPartIDs
|
||||
readTargetPartIDsFromSDK: typeof readTargetPartIDsFromSDK
|
||||
}
|
||||
|
||||
const thinkingPrependDeps: ThinkingPrependDeps = {
|
||||
isSqliteBackend,
|
||||
patchPart,
|
||||
log,
|
||||
findLastThinkingPart,
|
||||
findLastThinkingPartFromSDK,
|
||||
readTargetPartIDs,
|
||||
readTargetPartIDsFromSDK,
|
||||
}
|
||||
|
||||
function readTargetPartIDs(messageID: string): string[] {
|
||||
return readParts(messageID)
|
||||
.map((part) => part.id)
|
||||
.filter((id): id is string => typeof id === "string")
|
||||
}
|
||||
|
||||
async function readTargetPartIDsFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
messageID: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
|
||||
const targetMessage = messages.find((message) => message.info?.id === messageID)
|
||||
if (!targetMessage?.parts) {
|
||||
return []
|
||||
}
|
||||
|
||||
return targetMessage.parts
|
||||
.map((part) => part.id)
|
||||
.filter((id): id is string => typeof id === "string")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function canPrependBeforeTargetParts(partID: string, targetPartIDs: string[]): boolean {
|
||||
const firstTargetPartID = [...targetPartIDs].sort((left, right) => left.localeCompare(right))[0]
|
||||
return !firstTargetPartID || partID.localeCompare(firstTargetPartID) < 0
|
||||
}
|
||||
|
||||
function isStoredSignedThinkingPart(part: StoredPart): part is StoredSignedThinkingPart {
|
||||
if (!THINKING_TYPES.has(part.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
return false
|
||||
}
|
||||
|
||||
const signature = Reflect.get(part, "signature")
|
||||
return typeof signature === "string" && signature.length > 0
|
||||
}
|
||||
|
||||
function isSDKSignedThinkingPart(part: SDKMessagePart): part is SDKSignedThinkingPart {
|
||||
if (!part.type || !THINKING_TYPES.has(part.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof part.id === "string"
|
||||
&& typeof (part as { signature?: unknown }).signature === "string"
|
||||
&& ((part as { signature?: string }).signature?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function toPatchBody(part: SDKSignedThinkingPart): Record<string, unknown> {
|
||||
return { ...part }
|
||||
}
|
||||
|
||||
function findLastThinkingPart(
|
||||
sessionID: string,
|
||||
beforeMessageID: string
|
||||
): StoredSignedThinkingPart | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
const currentIndex = messages.findIndex((message) => message.id === beforeMessageID)
|
||||
if (currentIndex === -1) return ""
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
@@ -22,63 +118,62 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st
|
||||
|
||||
const parts = readParts(message.id)
|
||||
for (const part of parts) {
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
const thinking = (part as { thinking?: string; text?: string }).thinking
|
||||
const reasoning = (part as { thinking?: string; text?: string }).text
|
||||
const content = thinking || reasoning
|
||||
if (content && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
if (isStoredSignedThinkingPart(part)) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return null
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
if (isSqliteBackend()) {
|
||||
export function prependThinkingPart(
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
deps: ThinkingPrependDeps = thinkingPrependDeps
|
||||
): boolean {
|
||||
if (deps.isSqliteBackend()) {
|
||||
log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)")
|
||||
return false
|
||||
}
|
||||
|
||||
const previousThinkingPart = deps.findLastThinkingPart(sessionID, messageID)
|
||||
if (!previousThinkingPart) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canPrependBeforeTargetParts(previousThinkingPart.id, deps.readTargetPartIDs(messageID))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const previousThinking = findLastThinkingContent(sessionID, messageID)
|
||||
|
||||
const partId = `prt_0000000000_${messageID}_thinking`
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: previousThinking || "[Continuing from previous reasoning]",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
writeFileSync(
|
||||
join(partDir, `${previousThinkingPart.id}.json`),
|
||||
JSON.stringify(previousThinkingPart, null, 2)
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findLastThinkingContentFromSDK(
|
||||
async function findLastThinkingPartFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
beforeMessageID: string
|
||||
): Promise<string> {
|
||||
): Promise<SDKSignedThinkingPart | null> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
|
||||
|
||||
const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID)
|
||||
if (currentIndex === -1) return ""
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
@@ -86,39 +181,43 @@ async function findLastThinkingContentFromSDK(
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type && THINKING_TYPES.has(part.type)) {
|
||||
const content = part.thinking || part.text
|
||||
if (content && content.trim().length > 0) return content
|
||||
if (isSDKSignedThinkingPart(part)) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return ""
|
||||
return null
|
||||
}
|
||||
return ""
|
||||
return null
|
||||
}
|
||||
|
||||
export async function prependThinkingPartAsync(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
messageID: string
|
||||
messageID: string,
|
||||
deps: ThinkingPrependDeps = thinkingPrependDeps
|
||||
): Promise<boolean> {
|
||||
const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID)
|
||||
const previousThinkingPart = await deps.findLastThinkingPartFromSDK(client, sessionID, messageID)
|
||||
if (!previousThinkingPart) {
|
||||
return false
|
||||
}
|
||||
|
||||
const partId = `prt_0000000000_${messageID}_thinking`
|
||||
const part: Record<string, unknown> = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: previousThinking || "[Continuing from previous reasoning]",
|
||||
synthetic: true,
|
||||
const targetPartIDs = await deps.readTargetPartIDsFromSDK(client, sessionID, messageID)
|
||||
if (!canPrependBeforeTargetParts(previousThinkingPart.id, targetPartIDs)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await patchPart(client, sessionID, messageID, partId, part)
|
||||
return await deps.patchPart(
|
||||
client,
|
||||
sessionID,
|
||||
messageID,
|
||||
previousThinkingPart.id,
|
||||
toPatchBody(previousThinkingPart)
|
||||
)
|
||||
} catch (error) {
|
||||
log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
|
||||
deps.log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +404,24 @@ describe("start-work hook", () => {
|
||||
expect(updateSpy).toHaveBeenCalledWith("ses-prometheus-to-sisyphus", "atlas")
|
||||
updateSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should stamp the outgoing message with Atlas so follow-up events keep the handoff", async () => {
|
||||
// given
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "ses-prometheus-to-atlas" },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe("Atlas (Plan Executor)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("worktree support", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clearBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { detectWorktreePath } from "./worktree-detector"
|
||||
import { parseUserRequest } from "./parse-user-request"
|
||||
@@ -23,6 +24,7 @@ interface StartWorkHookInput {
|
||||
}
|
||||
|
||||
interface StartWorkHookOutput {
|
||||
message?: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
@@ -79,6 +81,9 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
|
||||
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
||||
updateSessionAgent(input.sessionID, "atlas")
|
||||
if (output.message) {
|
||||
output.message["agent"] = getAgentDisplayName("atlas")
|
||||
}
|
||||
|
||||
const existingState = readBoulderState(ctx.directory)
|
||||
const sessionId = input.sessionID
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
declare const describe: (name: string, fn: () => void) => void
|
||||
declare const it: (name: string, fn: () => void | Promise<void>) => void
|
||||
declare const expect: <T>(value: T) => {
|
||||
toBe(expected: T): void
|
||||
toEqual(expected: unknown): void
|
||||
toHaveLength(expected: number): void
|
||||
}
|
||||
|
||||
import { createThinkingBlockValidatorHook } from "./hook"
|
||||
|
||||
type TestPart = {
|
||||
type: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
signature?: string
|
||||
synthetic?: boolean
|
||||
}
|
||||
|
||||
type TestMessage = {
|
||||
info: { role: "assistant" | "user" }
|
||||
parts: TestPart[]
|
||||
}
|
||||
|
||||
async function runTransform(messages: TestMessage[]): Promise<void> {
|
||||
const hook = createThinkingBlockValidatorHook()
|
||||
const transform = hook["experimental.chat.messages.transform"]
|
||||
|
||||
if (!transform) {
|
||||
throw new Error("missing thinking block validator transform")
|
||||
}
|
||||
|
||||
await transform({}, { messages: messages as never })
|
||||
}
|
||||
|
||||
describe("createThinkingBlockValidatorHook", () => {
|
||||
it("injects signed thinking history verbatim", async () => {
|
||||
//#given
|
||||
const signedThinkingPart: TestPart = {
|
||||
type: "thinking",
|
||||
thinking: "plan",
|
||||
signature: "signed-thinking",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [signedThinkingPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts[0]).toBe(signedThinkingPart)
|
||||
})
|
||||
|
||||
it("injects signed redacted_thinking history verbatim", async () => {
|
||||
//#given
|
||||
const signedRedactedThinkingPart: TestPart = {
|
||||
type: "redacted_thinking",
|
||||
signature: "signed-redacted-thinking",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [signedRedactedThinkingPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "tool_use" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts[0]).toBe(signedRedactedThinkingPart)
|
||||
})
|
||||
|
||||
it("skips hook when history contains reasoning only", async () => {
|
||||
//#given
|
||||
const reasoningPart: TestPart = {
|
||||
type: "reasoning",
|
||||
text: "internal reasoning",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [reasoningPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||
})
|
||||
|
||||
it("skips hook when no signed history exists", async () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "thinking", thinking: "draft" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||
})
|
||||
|
||||
it("skips hook when history contains synthetic signed blocks only", async () => {
|
||||
//#given
|
||||
const syntheticSignedPart: TestPart = {
|
||||
type: "thinking",
|
||||
thinking: "synthetic",
|
||||
signature: "synthetic-signature",
|
||||
synthetic: true,
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [syntheticSignedPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||
})
|
||||
|
||||
it("does not reinject when the message already starts with redacted_thinking", async () => {
|
||||
//#given
|
||||
const signedThinkingPart: TestPart = {
|
||||
type: "thinking",
|
||||
thinking: "plan",
|
||||
signature: "signed-thinking",
|
||||
}
|
||||
const leadingRedactedThinkingPart: TestPart = {
|
||||
type: "redacted_thinking",
|
||||
signature: "existing-redacted-thinking",
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [signedThinkingPart],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [leadingRedactedThinkingPart, { type: "text", text: "continue" }],
|
||||
},
|
||||
] satisfies TestMessage[]
|
||||
|
||||
//#when
|
||||
await runTransform(messages)
|
||||
|
||||
//#then
|
||||
expect(messages[1]?.parts[0]).toBe(leadingRedactedThinkingPart)
|
||||
expect(messages[1]?.parts).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -21,18 +21,6 @@ interface MessageWithParts {
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
thinking?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface MessageInfoExtended {
|
||||
id: string
|
||||
role: string
|
||||
sessionID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
@@ -40,25 +28,39 @@ type MessagesTransformHook = {
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has extended thinking enabled
|
||||
* Uses patterns from think-mode/switcher.ts for consistency
|
||||
*/
|
||||
function isExtendedThinkingModel(modelID: string): boolean {
|
||||
if (!modelID) return false
|
||||
const lower = modelID.toLowerCase()
|
||||
type SignedThinkingPart = Part & {
|
||||
type: "thinking" | "redacted_thinking"
|
||||
thinking?: string
|
||||
signature: string
|
||||
synthetic?: boolean
|
||||
}
|
||||
|
||||
// Check for explicit thinking/high variants (always enabled)
|
||||
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
||||
return true
|
||||
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
||||
const type = part.type as string
|
||||
if (type !== "thinking" && type !== "redacted_thinking") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for thinking-capable models (claude-4 family, claude-3)
|
||||
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
||||
return (
|
||||
lower.includes("claude-sonnet-4") ||
|
||||
lower.includes("claude-opus-4") ||
|
||||
lower.includes("claude-3")
|
||||
const signature = (part as { signature?: unknown }).signature
|
||||
const synthetic = (part as { synthetic?: unknown }).synthetic
|
||||
return typeof signature === "string" && signature.length > 0 && synthetic !== true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any Anthropic-signed thinking blocks in the message history.
|
||||
*
|
||||
* Only returns true for real `type: "thinking"` blocks with a valid `signature`.
|
||||
* GPT reasoning blocks (`type: "reasoning"`) are intentionally excluded — they
|
||||
* have no Anthropic signature and must never be forwarded to the Anthropic API.
|
||||
*
|
||||
* Model-name checks are unreliable (miss GPT+thinking, custom model IDs, etc.)
|
||||
* so we inspect the messages themselves.
|
||||
*/
|
||||
function hasSignedThinkingBlocksInHistory(messages: MessageWithParts[]): boolean {
|
||||
return messages.some(
|
||||
m =>
|
||||
m.info.role === "assistant" &&
|
||||
m.parts?.some((p: Part) => isSignedThinkingPart(p)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,57 +85,51 @@ function startsWithThinkingBlock(parts: Part[]): boolean {
|
||||
|
||||
const firstPart = parts[0]
|
||||
const type = firstPart.type as string
|
||||
return type === "thinking" || type === "reasoning"
|
||||
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent thinking content from previous assistant messages
|
||||
* Find the most recent Anthropic-signed thinking part from previous assistant messages.
|
||||
*
|
||||
* Returns the original Part object (including its `signature` field) so it can
|
||||
* be reused verbatim in another message. Only `type: "thinking"` blocks with
|
||||
* both a `signature` and `thinking` field are returned — GPT `type: "reasoning"`
|
||||
* blocks are excluded because they lack an Anthropic signature and would be
|
||||
* rejected by the API with "Invalid `signature` in `thinking` block".
|
||||
* Synthetic parts injected by a previous run of this hook are also skipped.
|
||||
*/
|
||||
function findPreviousThinkingContent(
|
||||
messages: MessageWithParts[],
|
||||
currentIndex: number
|
||||
): string {
|
||||
function findPreviousThinkingPart(messages: MessageWithParts[], currentIndex: number): SignedThinkingPart | null {
|
||||
// Search backwards from current message
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Look for thinking parts
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const type = part.type as string
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
|
||||
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||
return thinking
|
||||
}
|
||||
}
|
||||
// Only Anthropic thinking blocks — type must be "thinking", not "reasoning"
|
||||
if (!isSignedThinkingPart(part)) continue
|
||||
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a thinking block to a message's parts array
|
||||
* Prepend an existing thinking block (with its original signature) to a
|
||||
* message's parts array.
|
||||
*
|
||||
* We reuse the original Part verbatim instead of creating a new one, because
|
||||
* the Anthropic API validates the `signature` field against the thinking
|
||||
* content. Any synthetic block we create ourselves would fail that check.
|
||||
*/
|
||||
function prependThinkingBlock(message: MessageWithParts, thinkingContent: string): void {
|
||||
function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void {
|
||||
if (!message.parts) {
|
||||
message.parts = []
|
||||
}
|
||||
|
||||
// Create synthetic thinking part
|
||||
const thinkingPart = {
|
||||
type: "thinking" as const,
|
||||
id: `prt_0000000000_synthetic_thinking`,
|
||||
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
|
||||
messageID: message.info.id,
|
||||
thinking: thinkingContent,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
// Prepend to parts array
|
||||
message.parts.unshift(thinkingPart as unknown as Part)
|
||||
message.parts.unshift(thinkingPart)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,12 +144,12 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the model info from the last user message
|
||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||
const modelID = (lastUserMessage?.info as unknown as MessageInfoExtended)?.modelID || ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
// Skip if there are no Anthropic-signed thinking blocks in history.
|
||||
// This is more reliable than checking model names — works for Claude,
|
||||
// GPT with thinking variants, or any future model. Crucially, GPT
|
||||
// reasoning blocks (type="reasoning", no signature) do NOT trigger this
|
||||
// hook — only real Anthropic thinking blocks do.
|
||||
if (!hasSignedThinkingBlocksInHistory(messages)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -166,13 +162,18 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
|
||||
// Check if message has content parts but doesn't start with thinking
|
||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Find thinking content from previous turns
|
||||
const previousThinking = findPreviousThinkingContent(messages, i)
|
||||
// Find the most recent real thinking part (with valid signature) from
|
||||
// previous turns. If none exists we cannot safely inject a thinking
|
||||
// block — a synthetic block without a signature would cause the API
|
||||
// to reject the request with "Invalid `signature` in `thinking` block".
|
||||
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
||||
|
||||
// Prepend thinking block with content from previous turn or placeholder
|
||||
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
|
||||
|
||||
prependThinkingBlock(msg, thinkingContent)
|
||||
if (previousThinkingPart) {
|
||||
prependThinkingBlock(msg, previousThinkingPart)
|
||||
}
|
||||
// If no real thinking part is available, skip injection entirely.
|
||||
// The downstream error (if any) is preferable to a guaranteed API
|
||||
// rejection caused by a signature-less synthetic thinking block.
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user