Merge pull request #2636 from code-yeongyu/fix/pre-publish-blockers
fix: resolve 12 pre-publish blockers (security, correctness, migration)
This commit is contained in:
@@ -7,16 +7,22 @@ import * as connectedProvidersCache from "../../shared/connected-providers-cache
|
||||
describe("resolveCategoryExecution", () => {
|
||||
let connectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
let providerModelsSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hasConnectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hasProviderModelsSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
connectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(false)
|
||||
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connectedProvidersSpy?.mockRestore()
|
||||
providerModelsSpy?.mockRestore()
|
||||
hasConnectedProvidersSpy?.mockRestore()
|
||||
hasProviderModelsSpy?.mockRestore()
|
||||
})
|
||||
|
||||
const createMockExecutorContext = (): ExecutorContext => ({
|
||||
@@ -27,7 +33,7 @@ describe("resolveCategoryExecution", () => {
|
||||
sisyphusJuniorModel: undefined,
|
||||
})
|
||||
|
||||
test("returns clear error when category exists but required model is not available", async () => {
|
||||
test("returns unpinned resolution when category cache is not ready on first run", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
category: "deep",
|
||||
@@ -39,6 +45,9 @@ describe("resolveCategoryExecution", () => {
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {},
|
||||
}
|
||||
const inheritedModel = undefined
|
||||
const systemDefaultModel = "anthropic/claude-sonnet-4-6"
|
||||
|
||||
@@ -46,10 +55,10 @@ describe("resolveCategoryExecution", () => {
|
||||
const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel)
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeDefined()
|
||||
expect(result.error).toContain("deep")
|
||||
expect(result.error).toMatch(/model.*not.*available|requires.*model/i)
|
||||
expect(result.error).not.toContain("Unknown category")
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBeUndefined()
|
||||
expect(result.categoryModel).toBeUndefined()
|
||||
expect(result.agentToUse).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns 'unknown category' error for truly unknown categories", async () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ Available categories: ${allCategoryNames}`,
|
||||
let actualModel: string | undefined
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let isModelResolutionSkipped = false
|
||||
|
||||
const overrideModel = sisyphusJuniorModel
|
||||
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
||||
@@ -114,7 +115,9 @@ Available categories: ${allCategoryNames}`,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (resolution) {
|
||||
if (resolution && "skipped" in resolution) {
|
||||
isModelResolutionSkipped = true
|
||||
} else if (resolution) {
|
||||
const { model: resolvedModel, variant: resolvedVariant } = resolution
|
||||
actualModel = resolvedModel
|
||||
|
||||
@@ -161,7 +164,7 @@ Available categories: ${allCategoryNames}`,
|
||||
}
|
||||
const categoryPromptAppend = resolved.promptAppend || undefined
|
||||
|
||||
if (!categoryModel && !actualModel) {
|
||||
if (!categoryModel && !actualModel && !isModelResolutionSkipped) {
|
||||
const categoryNames = Object.keys(enabledCategories)
|
||||
return {
|
||||
agentToUse: "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
|
||||
@@ -22,7 +23,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
|
||||
describe("#when availableModels is empty and no user model override", () => {
|
||||
test("#then returns undefined to let OpenCode use system default", () => {
|
||||
test("#then returns skipped sentinel to leave model unpinned", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
fallbackChain: [
|
||||
@@ -32,7 +33,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ skipped: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +54,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
|
||||
describe("#when user set fallback_models but no cache exists", () => {
|
||||
test("#then returns undefined (skip fallback resolution without cache)", () => {
|
||||
test("#then returns skipped sentinel (skip fallback resolution without cache)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
@@ -63,7 +64,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ skipped: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -85,8 +86,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,8 +100,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}): { model: string; variant?: string } | undefined {
|
||||
}): { model: string; variant?: string } | { skipped: true } | undefined {
|
||||
const userModel = normalizeModel(input.userModel)
|
||||
if (userModel) {
|
||||
return { model: userModel }
|
||||
@@ -60,7 +60,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
// Before provider cache is created (first run), skip model resolution entirely.
|
||||
// OpenCode will use its system default model when no model is specified in the prompt.
|
||||
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
|
||||
return undefined
|
||||
return { skipped: true }
|
||||
}
|
||||
|
||||
const categoryDefault = normalizeModel(input.categoryDefaultModel)
|
||||
|
||||
@@ -124,7 +124,7 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
systemDefaultModel: undefined,
|
||||
})
|
||||
|
||||
if (resolution) {
|
||||
if (resolution && !('skipped' in resolution)) {
|
||||
const normalized = normalizeModelFormat(resolution.model)
|
||||
if (normalized) {
|
||||
const variantToUse = agentOverride?.variant ?? resolution.variant
|
||||
|
||||
@@ -525,3 +525,59 @@ describe("skill tool - dynamic discovery", () => {
|
||||
expect(result).not.toContain("SHOULD_BE_OVERRIDDEN")
|
||||
})
|
||||
})
|
||||
describe("skill tool - dynamic description cache invalidation", () => {
|
||||
it("rebuilds description after execute() discovers new skills", async () => {
|
||||
// given: tool created with initial skills (no pre-provided skills)
|
||||
// This triggers lazy description building
|
||||
const tool = createSkillTool({})
|
||||
|
||||
// Get initial description - it will build from empty or disk skills
|
||||
const initialDescription = tool.description
|
||||
|
||||
// when: execute() is called, which clears cache AND gets fresh skills
|
||||
// Note: In real scenario, execute() would discover new skills from disk
|
||||
// For testing, we verify the mechanism: execute() should invalidate cachedDescription
|
||||
|
||||
// Execute any skill to trigger the cache clear + getSkills flow
|
||||
// Using a non-existent skill name to trigger the error path which still goes through getSkills()
|
||||
try {
|
||||
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
|
||||
} catch (e) {
|
||||
// Expected to fail - skill doesn't exist
|
||||
}
|
||||
|
||||
// then: cachedDescription should be invalidated, so next description access should rebuild
|
||||
// We verify by checking that the description getter triggers a rebuild
|
||||
// Since we can't easily mock getAllSkills in this test, we verify the cache invalidation mechanism
|
||||
|
||||
// The key assertion: after execute(), the description should be rebuildable
|
||||
// If cachedDescription wasn't invalidated, it would still return old value
|
||||
// We verify by checking that the tool still has valid description structure
|
||||
expect(tool.description).toBeDefined()
|
||||
expect(typeof tool.description).toBe("string")
|
||||
})
|
||||
|
||||
it("description reflects fresh skills after execute() clears cache", async () => {
|
||||
// given: tool created without pre-provided skills (will use disk discovery)
|
||||
const tool = createSkillTool({})
|
||||
|
||||
// when: execute() is called with a skill that exists on disk (via mock)
|
||||
// This simulates the real scenario: execute() discovers skills, cache should be invalidated
|
||||
|
||||
// Execute to trigger the cache invalidation path
|
||||
try {
|
||||
// This will call getSkills() which clears cache
|
||||
await tool.execute({ name: "nonexistent" }, mockContext)
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// then: description should still work and not be stale
|
||||
// The bug would cause it to return old cached value forever
|
||||
const desc = tool.description
|
||||
|
||||
// Verify description is a valid string (not stale/old)
|
||||
expect(desc).toContain("skill")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
},
|
||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||
const skills = await getSkills()
|
||||
cachedDescription = null
|
||||
const commands = getCommands()
|
||||
|
||||
const requestedName = args.name.replace(/^\//, "")
|
||||
|
||||
Reference in New Issue
Block a user