refactor: major codebase cleanup - BDD comments, file splitting, bug fixes (#1350)
* style(tests): normalize BDD comments from '// #given' to '// given'
- Replace 4,668 Python-style BDD comments across 107 test files
- Patterns changed: // #given -> // given, // #when -> // when, // #then -> // then
- Also handles no-space variants: //#given -> // given
* fix(rules-injector): prefer output.metadata.filePath over output.title
- Extract file path resolution to dedicated output-path.ts module
- Prefer metadata.filePath which contains actual file path
- Fall back to output.title only when metadata unavailable
- Fixes issue where rules weren't injected when tool output title was a label
* feat(slashcommand): add optional user_message parameter
- Add user_message optional parameter for command arguments
- Model can now call: command='publish' user_message='patch'
- Improves error messages with clearer format guidance
- Helps LLMs understand correct parameter usage
* feat(hooks): restore compaction-context-injector hook
- Restore hook deleted in cbbc7bd0 for session compaction context
- Injects 7 mandatory sections: User Requests, Final Goal, Work Completed,
Remaining Tasks, Active Working Context, MUST NOT Do, Agent Verification State
- Re-register in hooks/index.ts and main plugin entry
* refactor(background-agent): split manager.ts into focused modules
- Extract constants.ts for TTL values and internal types (52 lines)
- Extract state.ts for TaskStateManager class (204 lines)
- Extract spawner.ts for task creation logic (244 lines)
- Extract result-handler.ts for completion handling (265 lines)
- Reduce manager.ts from 1377 to 755 lines (45% reduction)
- Maintain backward compatible exports
* refactor(agents): split prometheus-prompt.ts into subdirectory
- Move 1196-line prometheus-prompt.ts to prometheus/ subdirectory
- Organize prompt sections into separate files for maintainability
- Update agents/index.ts exports
* refactor(delegate-task): split tools.ts into focused modules
- Extract categories.ts for category definitions and routing
- Extract executor.ts for task execution logic
- Extract helpers.ts for utility functions
- Extract prompt-builder.ts for prompt construction
- Reduce tools.ts complexity with cleaner separation of concerns
* refactor(builtin-skills): split skills.ts into individual skill files
- Move each skill to dedicated file in skills/ subdirectory
- Create barrel export for backward compatibility
- Improve maintainability with focused skill modules
* chore: update import paths and lockfile
- Update prometheus import path after refactor
- Update bun.lock
* fix(tests): complete BDD comment normalization
- Fix remaining #when/#then patterns missed by initial sed
- Affected: state.test.ts, events.test.ts
---------
Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { AGENT_MODEL_REQUIREMENTS } from "./model-requirements"
|
||||
describe("Agent Config Integration", () => {
|
||||
describe("Old format config migration", () => {
|
||||
test("migrates old format agent keys to lowercase", () => {
|
||||
// #given - config with old format keys
|
||||
// given - config with old format keys
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
Atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
@@ -15,52 +15,52 @@ describe("Agent Config Integration", () => {
|
||||
"Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-5" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
// when - migration is applied
|
||||
const result = migrateAgentNames(oldConfig)
|
||||
|
||||
// #then - keys are lowercase
|
||||
// then - keys are lowercase
|
||||
expect(result.migrated).toHaveProperty("sisyphus")
|
||||
expect(result.migrated).toHaveProperty("atlas")
|
||||
expect(result.migrated).toHaveProperty("prometheus")
|
||||
expect(result.migrated).toHaveProperty("metis")
|
||||
expect(result.migrated).toHaveProperty("momus")
|
||||
|
||||
// #then - old keys are removed
|
||||
// then - old keys are removed
|
||||
expect(result.migrated).not.toHaveProperty("Sisyphus")
|
||||
expect(result.migrated).not.toHaveProperty("Atlas")
|
||||
expect(result.migrated).not.toHaveProperty("Prometheus (Planner)")
|
||||
expect(result.migrated).not.toHaveProperty("Metis (Plan Consultant)")
|
||||
expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)")
|
||||
|
||||
// #then - values are preserved
|
||||
// then - values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
|
||||
// #then - changed flag is true
|
||||
// then - changed flag is true
|
||||
expect(result.changed).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves already lowercase keys", () => {
|
||||
// #given - config with lowercase keys
|
||||
// given - config with lowercase keys
|
||||
const config = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
// when - migration is applied
|
||||
const result = migrateAgentNames(config)
|
||||
|
||||
// #then - keys remain unchanged
|
||||
// then - keys remain unchanged
|
||||
expect(result.migrated).toEqual(config)
|
||||
|
||||
// #then - changed flag is false
|
||||
// then - changed flag is false
|
||||
expect(result.changed).toBe(false)
|
||||
})
|
||||
|
||||
test("handles mixed case config", () => {
|
||||
// #given - config with mixed old and new format
|
||||
// given - config with mixed old and new format
|
||||
const mixedConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
@@ -68,30 +68,30 @@ describe("Agent Config Integration", () => {
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
// when - migration is applied
|
||||
const result = migrateAgentNames(mixedConfig)
|
||||
|
||||
// #then - all keys are lowercase
|
||||
// then - all keys are lowercase
|
||||
expect(result.migrated).toHaveProperty("sisyphus")
|
||||
expect(result.migrated).toHaveProperty("oracle")
|
||||
expect(result.migrated).toHaveProperty("prometheus")
|
||||
expect(result.migrated).toHaveProperty("librarian")
|
||||
expect(Object.keys(result.migrated).every((key) => key === key.toLowerCase())).toBe(true)
|
||||
|
||||
// #then - changed flag is true
|
||||
// then - changed flag is true
|
||||
expect(result.changed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Display name resolution", () => {
|
||||
test("returns correct display names for all builtin agents", () => {
|
||||
// #given - lowercase config keys
|
||||
// given - lowercase config keys
|
||||
const agents = ["sisyphus", "atlas", "prometheus", "metis", "momus", "oracle", "librarian", "explore", "multimodal-looker"]
|
||||
|
||||
// #when - display names are requested
|
||||
// when - display names are requested
|
||||
const displayNames = agents.map((agent) => getAgentDisplayName(agent))
|
||||
|
||||
// #then - display names are correct
|
||||
// then - display names are correct
|
||||
expect(displayNames).toContain("Sisyphus (Ultraworker)")
|
||||
expect(displayNames).toContain("Atlas (Plan Execution Orchestrator)")
|
||||
expect(displayNames).toContain("Prometheus (Plan Builder)")
|
||||
@@ -104,13 +104,13 @@ describe("Agent Config Integration", () => {
|
||||
})
|
||||
|
||||
test("handles lowercase keys case-insensitively", () => {
|
||||
// #given - various case formats of lowercase keys
|
||||
// given - various case formats of lowercase keys
|
||||
const keys = ["Sisyphus", "Atlas", "SISYPHUS", "atlas", "prometheus", "PROMETHEUS"]
|
||||
|
||||
// #when - display names are requested
|
||||
// when - display names are requested
|
||||
const displayNames = keys.map((key) => getAgentDisplayName(key))
|
||||
|
||||
// #then - correct display names are returned
|
||||
// then - correct display names are returned
|
||||
expect(displayNames[0]).toBe("Sisyphus (Ultraworker)")
|
||||
expect(displayNames[1]).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
expect(displayNames[2]).toBe("Sisyphus (Ultraworker)")
|
||||
@@ -120,103 +120,103 @@ describe("Agent Config Integration", () => {
|
||||
})
|
||||
|
||||
test("returns original key for unknown agents", () => {
|
||||
// #given - unknown agent key
|
||||
// given - unknown agent key
|
||||
const unknownKey = "custom-agent"
|
||||
|
||||
// #when - display name is requested
|
||||
// when - display name is requested
|
||||
const displayName = getAgentDisplayName(unknownKey)
|
||||
|
||||
// #then - original key is returned
|
||||
// then - original key is returned
|
||||
expect(displayName).toBe(unknownKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model requirements integration", () => {
|
||||
test("all model requirements use lowercase keys", () => {
|
||||
// #given - AGENT_MODEL_REQUIREMENTS object
|
||||
// given - AGENT_MODEL_REQUIREMENTS object
|
||||
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #when - checking key format
|
||||
// when - checking key format
|
||||
const allLowercase = agentKeys.every((key) => key === key.toLowerCase())
|
||||
|
||||
// #then - all keys are lowercase
|
||||
// then - all keys are lowercase
|
||||
expect(allLowercase).toBe(true)
|
||||
})
|
||||
|
||||
test("model requirements include all builtin agents", () => {
|
||||
// #given - expected builtin agents
|
||||
// given - expected builtin agents
|
||||
const expectedAgents = ["sisyphus", "atlas", "prometheus", "metis", "momus", "oracle", "librarian", "explore", "multimodal-looker"]
|
||||
|
||||
// #when - checking AGENT_MODEL_REQUIREMENTS
|
||||
// when - checking AGENT_MODEL_REQUIREMENTS
|
||||
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all expected agents are present
|
||||
// then - all expected agents are present
|
||||
for (const agent of expectedAgents) {
|
||||
expect(agentKeys).toContain(agent)
|
||||
}
|
||||
})
|
||||
|
||||
test("no uppercase keys in model requirements", () => {
|
||||
// #given - AGENT_MODEL_REQUIREMENTS object
|
||||
// given - AGENT_MODEL_REQUIREMENTS object
|
||||
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #when - checking for uppercase keys
|
||||
// when - checking for uppercase keys
|
||||
const uppercaseKeys = agentKeys.filter((key) => key !== key.toLowerCase())
|
||||
|
||||
// #then - no uppercase keys exist
|
||||
// then - no uppercase keys exist
|
||||
expect(uppercaseKeys).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("End-to-end config flow", () => {
|
||||
test("old config migrates and displays correctly", () => {
|
||||
// #given - old format config
|
||||
// given - old format config
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5", temperature: 0.1 },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when - config is migrated
|
||||
// when - config is migrated
|
||||
const result = migrateAgentNames(oldConfig)
|
||||
|
||||
// #then - keys are lowercase
|
||||
// then - keys are lowercase
|
||||
expect(result.migrated).toHaveProperty("sisyphus")
|
||||
expect(result.migrated).toHaveProperty("prometheus")
|
||||
|
||||
// #when - display names are retrieved
|
||||
// when - display names are retrieved
|
||||
const sisyphusDisplay = getAgentDisplayName("sisyphus")
|
||||
const prometheusDisplay = getAgentDisplayName("prometheus")
|
||||
|
||||
// #then - display names are correct
|
||||
// then - display names are correct
|
||||
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
|
||||
expect(prometheusDisplay).toBe("Prometheus (Plan Builder)")
|
||||
|
||||
// #then - config values are preserved
|
||||
// then - config values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5", temperature: 0.1 })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("new config works without migration", () => {
|
||||
// #given - new format config (already lowercase)
|
||||
// given - new format config (already lowercase)
|
||||
const newConfig = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when - migration is applied (should be no-op)
|
||||
// when - migration is applied (should be no-op)
|
||||
const result = migrateAgentNames(newConfig)
|
||||
|
||||
// #then - config is unchanged
|
||||
// then - config is unchanged
|
||||
expect(result.migrated).toEqual(newConfig)
|
||||
|
||||
// #then - changed flag is false
|
||||
// then - changed flag is false
|
||||
expect(result.changed).toBe(false)
|
||||
|
||||
// #when - display names are retrieved
|
||||
// when - display names are retrieved
|
||||
const sisyphusDisplay = getAgentDisplayName("sisyphus")
|
||||
const atlasDisplay = getAgentDisplayName("atlas")
|
||||
|
||||
// #then - display names are correct
|
||||
// then - display names are correct
|
||||
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
|
||||
expect(atlasDisplay).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
})
|
||||
|
||||
@@ -3,141 +3,141 @@ import { AGENT_DISPLAY_NAMES, getAgentDisplayName } from "./agent-display-names"
|
||||
|
||||
describe("getAgentDisplayName", () => {
|
||||
it("returns display name for lowercase config key (new format)", () => {
|
||||
// #given config key "sisyphus"
|
||||
// given config key "sisyphus"
|
||||
const configKey = "sisyphus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Sisyphus (Ultraworker)"
|
||||
// then returns "Sisyphus (Ultraworker)"
|
||||
expect(result).toBe("Sisyphus (Ultraworker)")
|
||||
})
|
||||
|
||||
it("returns display name for uppercase config key (old format - case-insensitive)", () => {
|
||||
// #given config key "Sisyphus" (old format)
|
||||
// given config key "Sisyphus" (old format)
|
||||
const configKey = "Sisyphus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Sisyphus (Ultraworker)" (case-insensitive lookup)
|
||||
// then returns "Sisyphus (Ultraworker)" (case-insensitive lookup)
|
||||
expect(result).toBe("Sisyphus (Ultraworker)")
|
||||
})
|
||||
|
||||
it("returns original key for unknown agents (fallback)", () => {
|
||||
// #given config key "custom-agent"
|
||||
// given config key "custom-agent"
|
||||
const configKey = "custom-agent"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "custom-agent" (original key unchanged)
|
||||
// then returns "custom-agent" (original key unchanged)
|
||||
expect(result).toBe("custom-agent")
|
||||
})
|
||||
|
||||
it("returns display name for atlas", () => {
|
||||
// #given config key "atlas"
|
||||
// given config key "atlas"
|
||||
const configKey = "atlas"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Atlas (Plan Execution Orchestrator)"
|
||||
// then returns "Atlas (Plan Execution Orchestrator)"
|
||||
expect(result).toBe("Atlas (Plan Execution Orchestrator)")
|
||||
})
|
||||
|
||||
it("returns display name for prometheus", () => {
|
||||
// #given config key "prometheus"
|
||||
// given config key "prometheus"
|
||||
const configKey = "prometheus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Prometheus (Plan Builder)"
|
||||
// then returns "Prometheus (Plan Builder)"
|
||||
expect(result).toBe("Prometheus (Plan Builder)")
|
||||
})
|
||||
|
||||
it("returns display name for sisyphus-junior", () => {
|
||||
// #given config key "sisyphus-junior"
|
||||
// given config key "sisyphus-junior"
|
||||
const configKey = "sisyphus-junior"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Sisyphus-Junior"
|
||||
// then returns "Sisyphus-Junior"
|
||||
expect(result).toBe("Sisyphus-Junior")
|
||||
})
|
||||
|
||||
it("returns display name for metis", () => {
|
||||
// #given config key "metis"
|
||||
// given config key "metis"
|
||||
const configKey = "metis"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Metis (Plan Consultant)"
|
||||
// then returns "Metis (Plan Consultant)"
|
||||
expect(result).toBe("Metis (Plan Consultant)")
|
||||
})
|
||||
|
||||
it("returns display name for momus", () => {
|
||||
// #given config key "momus"
|
||||
// given config key "momus"
|
||||
const configKey = "momus"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "Momus (Plan Reviewer)"
|
||||
// then returns "Momus (Plan Reviewer)"
|
||||
expect(result).toBe("Momus (Plan Reviewer)")
|
||||
})
|
||||
|
||||
it("returns display name for oracle", () => {
|
||||
// #given config key "oracle"
|
||||
// given config key "oracle"
|
||||
const configKey = "oracle"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "oracle"
|
||||
// then returns "oracle"
|
||||
expect(result).toBe("oracle")
|
||||
})
|
||||
|
||||
it("returns display name for librarian", () => {
|
||||
// #given config key "librarian"
|
||||
// given config key "librarian"
|
||||
const configKey = "librarian"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "librarian"
|
||||
// then returns "librarian"
|
||||
expect(result).toBe("librarian")
|
||||
})
|
||||
|
||||
it("returns display name for explore", () => {
|
||||
// #given config key "explore"
|
||||
// given config key "explore"
|
||||
const configKey = "explore"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "explore"
|
||||
// then returns "explore"
|
||||
expect(result).toBe("explore")
|
||||
})
|
||||
|
||||
it("returns display name for multimodal-looker", () => {
|
||||
// #given config key "multimodal-looker"
|
||||
// given config key "multimodal-looker"
|
||||
const configKey = "multimodal-looker"
|
||||
|
||||
// #when getAgentDisplayName called
|
||||
// when getAgentDisplayName called
|
||||
const result = getAgentDisplayName(configKey)
|
||||
|
||||
// #then returns "multimodal-looker"
|
||||
// then returns "multimodal-looker"
|
||||
expect(result).toBe("multimodal-looker")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AGENT_DISPLAY_NAMES", () => {
|
||||
it("contains all expected agent mappings", () => {
|
||||
// #given expected mappings
|
||||
// given expected mappings
|
||||
const expectedMappings = {
|
||||
sisyphus: "Sisyphus (Ultraworker)",
|
||||
atlas: "Atlas (Plan Execution Orchestrator)",
|
||||
@@ -151,8 +151,8 @@ describe("AGENT_DISPLAY_NAMES", () => {
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
}
|
||||
|
||||
// #when checking the constant
|
||||
// #then contains all expected mappings
|
||||
// when checking the constant
|
||||
// then contains all expected mappings
|
||||
expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings)
|
||||
})
|
||||
})
|
||||
@@ -4,33 +4,33 @@ import { applyAgentVariant, resolveAgentVariant, resolveVariantForModel } from "
|
||||
|
||||
describe("resolveAgentVariant", () => {
|
||||
test("returns undefined when agent name missing", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveAgentVariant(config)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns agent override variant", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: { variant: "low" },
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveAgentVariant(config, "sisyphus")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("low")
|
||||
})
|
||||
|
||||
test("returns category variant when agent uses category", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: { category: "ultrabrain" },
|
||||
@@ -40,17 +40,17 @@ describe("resolveAgentVariant", () => {
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveAgentVariant(config, "sisyphus")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("xhigh")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyAgentVariant", () => {
|
||||
test("sets variant when message is undefined", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: { variant: "low" },
|
||||
@@ -58,15 +58,15 @@ describe("applyAgentVariant", () => {
|
||||
} as OhMyOpenCodeConfig
|
||||
const message: { variant?: string } = {}
|
||||
|
||||
// #when
|
||||
// when
|
||||
applyAgentVariant(config, "sisyphus", message)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(message.variant).toBe("low")
|
||||
})
|
||||
|
||||
test("does not override existing variant", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: { variant: "low" },
|
||||
@@ -74,89 +74,89 @@ describe("applyAgentVariant", () => {
|
||||
} as OhMyOpenCodeConfig
|
||||
const message = { variant: "max" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
applyAgentVariant(config, "sisyphus", message)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(message.variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveVariantForModel", () => {
|
||||
test("returns correct variant for anthropic provider", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("max")
|
||||
})
|
||||
|
||||
test("returns correct variant for openai provider", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("returns undefined for provider with no variant in chain", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "google", modelID: "gemini-3-pro" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for provider not in chain", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "unknown-provider", modelID: "some-model" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for unknown agent", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "nonexistent-agent", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns variant for zai-coding-plan provider without variant", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "zai-coding-plan", modelID: "glm-4.7" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to category chain when agent has no requirement", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
"custom-agent": { category: "ultrabrain" },
|
||||
@@ -164,34 +164,34 @@ describe("resolveVariantForModel", () => {
|
||||
} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2-codex" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "custom-agent", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("returns correct variant for oracle agent with openai", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "oracle", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("high")
|
||||
})
|
||||
|
||||
test("returns correct variant for oracle agent with anthropic", () => {
|
||||
// #given
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "oracle", model)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,123 +5,123 @@ type AnyObject = Record<string, unknown>
|
||||
|
||||
describe("isPlainObject", () => {
|
||||
test("returns false for null", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = null
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = undefined
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for string", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = "hello"
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for number", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = 42
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for boolean", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = true
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for array", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = [1, 2, 3]
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for Date", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = new Date()
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for RegExp", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = /test/
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for plain object", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = { a: 1 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for empty object", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = {}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for nested object", () => {
|
||||
//#given
|
||||
// given
|
||||
const value = { a: { b: 1 } }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = isPlainObject(value)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -129,179 +129,179 @@ describe("isPlainObject", () => {
|
||||
describe("deepMerge", () => {
|
||||
describe("basic merging", () => {
|
||||
test("merges two simple objects", () => {
|
||||
//#given
|
||||
// given
|
||||
const base: AnyObject = { a: 1 }
|
||||
const override: AnyObject = { b: 2 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
test("override value takes precedence", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = { a: 1 }
|
||||
const override = { a: 2 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: 2 })
|
||||
})
|
||||
|
||||
test("deeply merges nested objects", () => {
|
||||
//#given
|
||||
// given
|
||||
const base: AnyObject = { a: { b: 1, c: 2 } }
|
||||
const override: AnyObject = { a: { b: 10 } }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: { b: 10, c: 2 } })
|
||||
})
|
||||
|
||||
test("handles multiple levels of nesting", () => {
|
||||
//#given
|
||||
// given
|
||||
const base: AnyObject = { a: { b: { c: { d: 1 } } } }
|
||||
const override: AnyObject = { a: { b: { c: { e: 2 } } } }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: { b: { c: { d: 1, e: 2 } } } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("returns undefined when both are undefined", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = undefined
|
||||
const override = undefined
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge<AnyObject>(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns override when base is undefined", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = undefined
|
||||
const override = { a: 1 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge<AnyObject>(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("returns base when override is undefined", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = { a: 1 }
|
||||
const override = undefined
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge<AnyObject>(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("preserves base value when override value is undefined", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = { a: 1, b: 2 }
|
||||
const override = { a: undefined, b: 3 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: 1, b: 3 })
|
||||
})
|
||||
|
||||
test("does not mutate base object", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = { a: 1, b: { c: 2 } }
|
||||
const override = { b: { c: 10 } }
|
||||
const originalBase = JSON.parse(JSON.stringify(base))
|
||||
|
||||
//#when
|
||||
// when
|
||||
deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(base).toEqual(originalBase)
|
||||
})
|
||||
})
|
||||
|
||||
describe("array handling", () => {
|
||||
test("replaces arrays instead of merging them", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = { arr: [1, 2] }
|
||||
const override = { arr: [3, 4, 5] }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ arr: [3, 4, 5] })
|
||||
})
|
||||
|
||||
test("replaces nested arrays", () => {
|
||||
//#given
|
||||
// given
|
||||
const base = { a: { arr: [1, 2, 3] } }
|
||||
const override = { a: { arr: [4] } }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: { arr: [4] } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("prototype pollution protection", () => {
|
||||
test("ignores __proto__ key", () => {
|
||||
//#given
|
||||
// given
|
||||
const base: AnyObject = { a: 1 }
|
||||
const override: AnyObject = JSON.parse('{"__proto__": {"polluted": true}, "b": 2}')
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
expect(({} as AnyObject).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores constructor key", () => {
|
||||
//#given
|
||||
// given
|
||||
const base: AnyObject = { a: 1 }
|
||||
const override: AnyObject = { constructor: { polluted: true }, b: 2 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result!.b).toBe(2)
|
||||
expect(result!["constructor"]).not.toEqual({ polluted: true })
|
||||
})
|
||||
|
||||
test("ignores prototype key", () => {
|
||||
//#given
|
||||
// given
|
||||
const base: AnyObject = { a: 1 }
|
||||
const override: AnyObject = { prototype: { polluted: true }, b: 2 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result!.b).toBe(2)
|
||||
expect(result!.prototype).toBeUndefined()
|
||||
})
|
||||
@@ -309,7 +309,7 @@ describe("deepMerge", () => {
|
||||
|
||||
describe("depth limit", () => {
|
||||
test("returns override when depth exceeds MAX_DEPTH", () => {
|
||||
//#given
|
||||
// given
|
||||
const createDeepObject = (depth: number, leaf: AnyObject): AnyObject => {
|
||||
if (depth === 0) return leaf
|
||||
return { nested: createDeepObject(depth - 1, leaf) }
|
||||
@@ -318,10 +318,10 @@ describe("deepMerge", () => {
|
||||
const base = createDeepObject(55, { baseKey: "base" })
|
||||
const override = createDeepObject(55, { overrideKey: "override" })
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = deepMerge(base, override)
|
||||
|
||||
//#then
|
||||
// then
|
||||
// Navigate to depth 55 (leaf level, beyond MAX_DEPTH of 50)
|
||||
let current: AnyObject = result as AnyObject
|
||||
for (let i = 0; i < 55; i++) {
|
||||
|
||||
@@ -17,16 +17,16 @@ describe("external-plugin-detector", () => {
|
||||
|
||||
describe("detectExternalNotificationPlugin", () => {
|
||||
test("should return detected=false when no plugins configured", () => {
|
||||
// #given - empty directory
|
||||
// #when
|
||||
// given - empty directory
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should return detected=false when only oh-my-opencode is configured", () => {
|
||||
// #given - opencode.json with only oh-my-opencode
|
||||
// given - opencode.json with only oh-my-opencode
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -34,17 +34,17 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["oh-my-opencode"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
expect(result.allPlugins).toContain("oh-my-opencode")
|
||||
})
|
||||
|
||||
test("should detect opencode-notifier plugin", () => {
|
||||
// #given - opencode.json with opencode-notifier
|
||||
// given - opencode.json with opencode-notifier
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -52,16 +52,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should detect opencode-notifier with version suffix", () => {
|
||||
// #given - opencode.json with versioned opencode-notifier
|
||||
// given - opencode.json with versioned opencode-notifier
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -69,16 +69,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier@1.2.3"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should detect @mohak34/opencode-notifier", () => {
|
||||
// #given - opencode.json with scoped package name
|
||||
// given - opencode.json with scoped package name
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -86,16 +86,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["oh-my-opencode", "@mohak34/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then - returns the matched known plugin pattern, not the full entry
|
||||
// then - returns the matched known plugin pattern, not the full entry
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toContain("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should handle JSONC format with comments", () => {
|
||||
// #given - opencode.jsonc with comments
|
||||
// given - opencode.jsonc with comments
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -109,10 +109,10 @@ describe("external-plugin-detector", () => {
|
||||
}`
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
@@ -120,7 +120,7 @@ describe("external-plugin-detector", () => {
|
||||
|
||||
describe("false positive prevention", () => {
|
||||
test("should NOT match my-opencode-notifier-fork (suffix variation)", () => {
|
||||
// #given - plugin with similar name but different suffix
|
||||
// given - plugin with similar name but different suffix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -128,16 +128,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["my-opencode-notifier-fork"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT match some-other-plugin/opencode-notifier-like (path with similar name)", () => {
|
||||
// #given - plugin path containing similar substring
|
||||
// given - plugin path containing similar substring
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -145,16 +145,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["some-other-plugin/opencode-notifier-like"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT match opencode-notifier-extended (prefix match but different package)", () => {
|
||||
// #given - plugin with prefix match but extended name
|
||||
// given - plugin with prefix match but extended name
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -162,16 +162,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["opencode-notifier-extended"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should match opencode-notifier exactly", () => {
|
||||
// #given - exact match
|
||||
// given - exact match
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -179,16 +179,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match opencode-notifier@1.2.3 (version suffix)", () => {
|
||||
// #given - version suffix
|
||||
// given - version suffix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -196,16 +196,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["opencode-notifier@1.2.3"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match @mohak34/opencode-notifier (scoped package)", () => {
|
||||
// #given - scoped package
|
||||
// given - scoped package
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -213,16 +213,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["@mohak34/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toContain("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match npm:opencode-notifier (npm prefix)", () => {
|
||||
// #given - npm prefix
|
||||
// given - npm prefix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -230,16 +230,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["npm:opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match npm:opencode-notifier@2.0.0 (npm prefix with version)", () => {
|
||||
// #given - npm prefix with version
|
||||
// given - npm prefix with version
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -247,16 +247,16 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["npm:opencode-notifier@2.0.0"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match file:///path/to/opencode-notifier (file path)", () => {
|
||||
// #given - file path
|
||||
// given - file path
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -264,10 +264,10 @@ describe("external-plugin-detector", () => {
|
||||
JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
@@ -275,10 +275,10 @@ describe("external-plugin-detector", () => {
|
||||
|
||||
describe("getNotificationConflictWarning", () => {
|
||||
test("should generate warning message with plugin name", () => {
|
||||
// #when
|
||||
// when
|
||||
const warning = getNotificationConflictWarning("opencode-notifier")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(warning).toContain("opencode-notifier")
|
||||
expect(warning).toContain("session.idle")
|
||||
expect(warning).toContain("auto-disabled")
|
||||
|
||||
@@ -3,30 +3,30 @@ import { createFirstMessageVariantGate } from "./first-message-variant"
|
||||
|
||||
describe("createFirstMessageVariantGate", () => {
|
||||
test("marks new sessions and clears after apply", () => {
|
||||
// #given
|
||||
// given
|
||||
const gate = createFirstMessageVariantGate()
|
||||
|
||||
// #when
|
||||
// when
|
||||
gate.markSessionCreated({ id: "session-1" })
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(gate.shouldOverride("session-1")).toBe(true)
|
||||
|
||||
// #when
|
||||
// when
|
||||
gate.markApplied("session-1")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(gate.shouldOverride("session-1")).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores forked sessions", () => {
|
||||
// #given
|
||||
// given
|
||||
const gate = createFirstMessageVariantGate()
|
||||
|
||||
// #when
|
||||
// when
|
||||
gate.markSessionCreated({ id: "session-2", parentID: "session-parent" })
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(gate.shouldOverride("session-2")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,34 +4,34 @@ import { parseFrontmatter } from "./frontmatter"
|
||||
describe("parseFrontmatter", () => {
|
||||
// #region backward compatibility
|
||||
test("parses simple key-value frontmatter", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
description: Test command
|
||||
agent: build
|
||||
---
|
||||
Body content`
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.description).toBe("Test command")
|
||||
expect(result.data.agent).toBe("build")
|
||||
expect(result.body).toBe("Body content")
|
||||
})
|
||||
|
||||
test("parses boolean values", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
subtask: true
|
||||
enabled: false
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<{ subtask: boolean; enabled: boolean }>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.subtask).toBe(true)
|
||||
expect(result.data.enabled).toBe(false)
|
||||
})
|
||||
@@ -39,7 +39,7 @@ Body`
|
||||
|
||||
// #region complex YAML (handoffs support)
|
||||
test("parses complex array frontmatter (speckit handoffs)", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
description: Execute planning workflow
|
||||
handoffs:
|
||||
@@ -58,10 +58,10 @@ Workflow instructions`
|
||||
handoffs: Array<{ label: string; agent: string; prompt: string; send?: boolean }>
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<TestMeta>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.description).toBe("Execute planning workflow")
|
||||
expect(result.data.handoffs).toHaveLength(2)
|
||||
expect(result.data.handoffs[0].label).toBe("Create Tasks")
|
||||
@@ -72,7 +72,7 @@ Workflow instructions`
|
||||
})
|
||||
|
||||
test("parses nested objects in frontmatter", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
name: test
|
||||
config:
|
||||
@@ -92,10 +92,10 @@ Content`
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<TestMeta>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.name).toBe("test")
|
||||
expect(result.data.config.timeout).toBe(5000)
|
||||
expect(result.data.config.retry).toBe(true)
|
||||
@@ -105,58 +105,58 @@ Content`
|
||||
|
||||
// #region edge cases
|
||||
test("handles content without frontmatter", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = "Just body content"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Just body content")
|
||||
})
|
||||
|
||||
test("handles empty frontmatter", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
|
||||
test("handles invalid YAML gracefully", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
invalid: yaml: syntax: here
|
||||
bad indentation
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then - should not throw, return empty data
|
||||
// then - should not throw, return empty data
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
|
||||
test("handles frontmatter with only whitespace", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
|
||||
---
|
||||
Body with whitespace-only frontmatter`
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body with whitespace-only frontmatter")
|
||||
})
|
||||
@@ -164,7 +164,7 @@ Body with whitespace-only frontmatter`
|
||||
|
||||
// #region mixed content
|
||||
test("preserves multiline body content", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
title: Test
|
||||
---
|
||||
@@ -173,22 +173,22 @@ Line 2
|
||||
|
||||
Line 4 after blank`
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<{ title: string }>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.title).toBe("Test")
|
||||
expect(result.body).toBe("Line 1\nLine 2\n\nLine 4 after blank")
|
||||
})
|
||||
|
||||
test("handles CRLF line endings", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = "---\r\ndescription: Test\r\n---\r\nBody"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<{ description: string }>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.description).toBe("Test")
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
@@ -196,7 +196,7 @@ Line 4 after blank`
|
||||
|
||||
// #region extra fields tolerance
|
||||
test("allows extra fields beyond typed interface", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
description: Test command
|
||||
agent: build
|
||||
@@ -216,10 +216,10 @@ Body content`
|
||||
agent: string
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<MinimalMeta>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.description).toBe("Test command")
|
||||
expect(result.data.agent).toBe("build")
|
||||
expect(result.body).toBe("Body content")
|
||||
@@ -234,7 +234,7 @@ Body content`
|
||||
})
|
||||
|
||||
test("extra fields do not interfere with expected fields", () => {
|
||||
// #given
|
||||
// given
|
||||
const content = `---
|
||||
description: Original description
|
||||
unknown_field: extra value
|
||||
@@ -249,10 +249,10 @@ Content`
|
||||
handoffs: Array<{ label: string; agent: string }>
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseFrontmatter<HandoffMeta>(content)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result.data.description).toBe("Original description")
|
||||
expect(result.data.handoffs).toHaveLength(1)
|
||||
expect(result.data.handoffs[0].label).toBe("Task 1")
|
||||
|
||||
@@ -5,46 +5,46 @@ import { join } from "node:path"
|
||||
|
||||
describe("parseJsonc", () => {
|
||||
test("parses plain JSON", () => {
|
||||
//#given
|
||||
// given
|
||||
const json = `{"key": "value"}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ key: string }>(json)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with line comments", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
// This is a comment
|
||||
"key": "value"
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with block comments", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
/* Block comment */
|
||||
"key": "value"
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with multi-line block comments", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
/* Multi-line
|
||||
comment
|
||||
@@ -52,56 +52,56 @@ describe("parseJsonc", () => {
|
||||
"key": "value"
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with trailing commas", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ key1: string; key2: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.key1).toBe("value1")
|
||||
expect(result.key2).toBe("value2")
|
||||
})
|
||||
|
||||
test("parses JSONC with trailing comma in array", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
"arr": [1, 2, 3,]
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ arr: number[] }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.arr).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("preserves URLs with // in strings", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
"url": "https://example.com"
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{ url: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.url).toBe("https://example.com")
|
||||
})
|
||||
|
||||
test("parses complex JSONC config", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{
|
||||
// This is an example config
|
||||
"agents": {
|
||||
@@ -111,58 +111,58 @@ describe("parseJsonc", () => {
|
||||
"disabled_agents": [],
|
||||
}`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsonc<{
|
||||
agents: { oracle: { model: string } }
|
||||
disabled_agents: string[]
|
||||
}>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(result.disabled_agents).toEqual([])
|
||||
})
|
||||
|
||||
test("throws on invalid JSON", () => {
|
||||
//#given
|
||||
// given
|
||||
const invalid = `{ "key": invalid }`
|
||||
|
||||
//#when
|
||||
//#then
|
||||
// when
|
||||
// then
|
||||
expect(() => parseJsonc(invalid)).toThrow()
|
||||
})
|
||||
|
||||
test("throws on unclosed string", () => {
|
||||
//#given
|
||||
// given
|
||||
const invalid = `{ "key": "unclosed }`
|
||||
|
||||
//#when
|
||||
//#then
|
||||
// when
|
||||
// then
|
||||
expect(() => parseJsonc(invalid)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseJsoncSafe", () => {
|
||||
test("returns data on valid JSONC", () => {
|
||||
//#given
|
||||
// given
|
||||
const jsonc = `{ "key": "value" }`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsoncSafe<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.data).not.toBeNull()
|
||||
expect(result.data?.key).toBe("value")
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("returns errors on invalid JSONC", () => {
|
||||
//#given
|
||||
// given
|
||||
const invalid = `{ "key": invalid }`
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = parseJsoncSafe(invalid)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.data).toBeNull()
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -173,7 +173,7 @@ describe("readJsoncFile", () => {
|
||||
const testFile = join(testDir, "config.jsonc")
|
||||
|
||||
test("reads and parses valid JSONC file", () => {
|
||||
//#given
|
||||
// given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
const content = `{
|
||||
// Comment
|
||||
@@ -181,10 +181,10 @@ describe("readJsoncFile", () => {
|
||||
}`
|
||||
writeFileSync(testFile, content)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = readJsoncFile<{ test: string }>(testFile)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.test).toBe("value")
|
||||
|
||||
@@ -192,25 +192,25 @@ describe("readJsoncFile", () => {
|
||||
})
|
||||
|
||||
test("returns null for non-existent file", () => {
|
||||
//#given
|
||||
// given
|
||||
const nonExistent = join(testDir, "does-not-exist.jsonc")
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = readJsoncFile(nonExistent)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for malformed JSON", () => {
|
||||
//#given
|
||||
// given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
writeFileSync(testFile, "{ invalid }")
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = readJsoncFile(testFile)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
@@ -221,16 +221,16 @@ describe("detectConfigFile", () => {
|
||||
const testDir = join(__dirname, ".test-detect")
|
||||
|
||||
test("prefers .jsonc over .json", () => {
|
||||
//#given
|
||||
// given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
const basePath = join(testDir, "config")
|
||||
writeFileSync(`${basePath}.json`, "{}")
|
||||
writeFileSync(`${basePath}.jsonc`, "{}")
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = detectConfigFile(basePath)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.format).toBe("jsonc")
|
||||
expect(result.path).toBe(`${basePath}.jsonc`)
|
||||
|
||||
@@ -238,15 +238,15 @@ describe("detectConfigFile", () => {
|
||||
})
|
||||
|
||||
test("detects .json when .jsonc doesn't exist", () => {
|
||||
//#given
|
||||
// given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
const basePath = join(testDir, "config")
|
||||
writeFileSync(`${basePath}.json`, "{}")
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = detectConfigFile(basePath)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.format).toBe("json")
|
||||
expect(result.path).toBe(`${basePath}.json`)
|
||||
|
||||
@@ -254,13 +254,13 @@ describe("detectConfigFile", () => {
|
||||
})
|
||||
|
||||
test("returns none when neither exists", () => {
|
||||
//#given
|
||||
// given
|
||||
const basePath = join(testDir, "nonexistent")
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = detectConfigFile(basePath)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.format).toBe("none")
|
||||
})
|
||||
})
|
||||
|
||||
+130
-130
@@ -13,17 +13,17 @@ import {
|
||||
|
||||
describe("migrateAgentNames", () => {
|
||||
test("migrates legacy OmO names to lowercase", () => {
|
||||
// #given: Config with legacy OmO agent names
|
||||
// given: Config with legacy OmO agent names
|
||||
const agents = {
|
||||
omo: { model: "anthropic/claude-opus-4-5" },
|
||||
OmO: { temperature: 0.5 },
|
||||
"OmO-Plan": { prompt: "custom prompt" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
// when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Legacy names should be migrated to lowercase
|
||||
// then: Legacy names should be migrated to lowercase
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["sisyphus"]).toEqual({ temperature: 0.5 })
|
||||
expect(migrated["prometheus"]).toEqual({ prompt: "custom prompt" })
|
||||
@@ -33,17 +33,17 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("preserves current agent names unchanged", () => {
|
||||
// #given: Config with current agent names
|
||||
// given: Config with current agent names
|
||||
const agents = {
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "google/gemini-3-flash" },
|
||||
explore: { model: "opencode/gpt-5-nano" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
// when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Current names should remain unchanged
|
||||
// then: Current names should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
|
||||
@@ -51,69 +51,69 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("handles case-insensitive migration", () => {
|
||||
// #given: Config with mixed case agent names
|
||||
// given: Config with mixed case agent names
|
||||
const agents = {
|
||||
SISYPHUS: { model: "test" },
|
||||
"planner-sisyphus": { prompt: "test" },
|
||||
"Orchestrator-Sisyphus": { model: "openai/gpt-5.2" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
// when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Case-insensitive lookup should migrate correctly
|
||||
// then: Case-insensitive lookup should migrate correctly
|
||||
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["prometheus"]).toEqual({ prompt: "test" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
})
|
||||
|
||||
test("passes through unknown agent names unchanged", () => {
|
||||
// #given: Config with unknown agent name
|
||||
// given: Config with unknown agent name
|
||||
const agents = {
|
||||
"custom-agent": { model: "custom/model" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
// when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Unknown names should pass through
|
||||
// then: Unknown names should pass through
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
|
||||
})
|
||||
|
||||
test("migrates orchestrator-sisyphus to atlas", () => {
|
||||
// #given: Config with legacy orchestrator-sisyphus agent name
|
||||
// given: Config with legacy orchestrator-sisyphus agent name
|
||||
const agents = {
|
||||
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
// when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: orchestrator-sisyphus should be migrated to atlas
|
||||
// then: orchestrator-sisyphus should be migrated to atlas
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(migrated["orchestrator-sisyphus"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates lowercase atlas to atlas", () => {
|
||||
// #given: Config with lowercase atlas agent name
|
||||
// given: Config with lowercase atlas agent name
|
||||
const agents = {
|
||||
atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
// when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: lowercase atlas should remain atlas (no change needed)
|
||||
// then: lowercase atlas should remain atlas (no change needed)
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("migrates Sisyphus variants to lowercase", () => {
|
||||
// #given agents config with "Sisyphus" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "sisyphus"
|
||||
// given agents config with "Sisyphus" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "sisyphus"
|
||||
const agents = { "Sisyphus": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -122,9 +122,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("migrates omo key to sisyphus", () => {
|
||||
// #given agents config with "omo" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "sisyphus"
|
||||
// given agents config with "omo" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "sisyphus"
|
||||
const agents = { "omo": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -133,9 +133,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("migrates Atlas variants to lowercase", () => {
|
||||
// #given agents config with "Atlas" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "atlas"
|
||||
// given agents config with "Atlas" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "atlas"
|
||||
const agents = { "Atlas": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -144,9 +144,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("migrates Prometheus variants to lowercase", () => {
|
||||
// #given agents config with "Prometheus (Planner)" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "prometheus"
|
||||
// given agents config with "Prometheus (Planner)" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "prometheus"
|
||||
const agents = { "Prometheus (Planner)": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -155,9 +155,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("migrates Metis variants to lowercase", () => {
|
||||
// #given agents config with "Metis (Plan Consultant)" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "metis"
|
||||
// given agents config with "Metis (Plan Consultant)" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "metis"
|
||||
const agents = { "Metis (Plan Consultant)": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -166,9 +166,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("migrates Momus variants to lowercase", () => {
|
||||
// #given agents config with "Momus (Plan Reviewer)" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "momus"
|
||||
// given agents config with "Momus (Plan Reviewer)" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "momus"
|
||||
const agents = { "Momus (Plan Reviewer)": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -177,9 +177,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("migrates Sisyphus-Junior to lowercase", () => {
|
||||
// #given agents config with "Sisyphus-Junior" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key becomes "sisyphus-junior"
|
||||
// given agents config with "Sisyphus-Junior" key
|
||||
// when migrateAgentNames called
|
||||
// then key becomes "sisyphus-junior"
|
||||
const agents = { "Sisyphus-Junior": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(true)
|
||||
@@ -188,9 +188,9 @@ describe("migrateAgentNames", () => {
|
||||
})
|
||||
|
||||
test("preserves lowercase passthrough", () => {
|
||||
// #given agents config with "oracle" key
|
||||
// #when migrateAgentNames called
|
||||
// #then key remains "oracle" (no change needed)
|
||||
// given agents config with "oracle" key
|
||||
// when migrateAgentNames called
|
||||
// then key remains "oracle" (no change needed)
|
||||
const agents = { "oracle": { model: "test" } }
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
expect(changed).toBe(false)
|
||||
@@ -200,13 +200,13 @@ describe("migrateAgentNames", () => {
|
||||
|
||||
describe("migrateHookNames", () => {
|
||||
test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => {
|
||||
// #given: Config with legacy hook name
|
||||
// given: Config with legacy hook name
|
||||
const hooks = ["anthropic-auto-compact", "comment-checker"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Legacy hook name should be migrated
|
||||
// then: Legacy hook name should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(migrated).toContain("comment-checker")
|
||||
@@ -215,55 +215,55 @@ describe("migrateHookNames", () => {
|
||||
})
|
||||
|
||||
test("preserves current hook names unchanged", () => {
|
||||
// #given: Config with current hook names
|
||||
// given: Config with current hook names
|
||||
const hooks = [
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"todo-continuation-enforcer",
|
||||
"session-recovery",
|
||||
]
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Current names should remain unchanged
|
||||
// then: Current names should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual(hooks)
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
|
||||
test("handles empty hooks array", () => {
|
||||
// #given: Empty hooks array
|
||||
// given: Empty hooks array
|
||||
const hooks: string[] = []
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Should return empty array with no changes
|
||||
// then: Should return empty array with no changes
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual([])
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
|
||||
test("migrates multiple legacy hook names", () => {
|
||||
// #given: Multiple legacy hook names (if more are added in future)
|
||||
// given: Multiple legacy hook names (if more are added in future)
|
||||
const hooks = ["anthropic-auto-compact"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: All legacy names should be migrated
|
||||
// then: All legacy names should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
|
||||
})
|
||||
|
||||
test("migrates sisyphus-orchestrator to atlas", () => {
|
||||
// #given: Config with legacy sisyphus-orchestrator hook
|
||||
// given: Config with legacy sisyphus-orchestrator hook
|
||||
const hooks = ["sisyphus-orchestrator", "comment-checker"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: sisyphus-orchestrator should be migrated to atlas
|
||||
// then: sisyphus-orchestrator should be migrated to atlas
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toContain("atlas")
|
||||
expect(migrated).toContain("comment-checker")
|
||||
@@ -272,13 +272,13 @@ describe("migrateHookNames", () => {
|
||||
})
|
||||
|
||||
test("removes obsolete hooks and returns them in removed array", () => {
|
||||
// #given: Config with removed hooks from v3.0.0
|
||||
// given: Config with removed hooks from v3.0.0
|
||||
const hooks = ["preemptive-compaction", "empty-message-sanitizer", "comment-checker"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Removed hooks should be filtered out
|
||||
// then: Removed hooks should be filtered out
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toEqual(["comment-checker"])
|
||||
expect(removed).toContain("preemptive-compaction")
|
||||
@@ -287,13 +287,13 @@ describe("migrateHookNames", () => {
|
||||
})
|
||||
|
||||
test("handles mixed migration and removal", () => {
|
||||
// #given: Config with both legacy rename and removed hooks
|
||||
// given: Config with both legacy rename and removed hooks
|
||||
const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Legacy should be renamed, removed should be filtered
|
||||
// then: Legacy should be renamed, removed should be filtered
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(migrated).toContain("atlas")
|
||||
@@ -306,22 +306,22 @@ describe("migrateConfigFile", () => {
|
||||
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
|
||||
|
||||
test("migrates omo_agent to sisyphus_agent", () => {
|
||||
// #given: Config with legacy omo_agent key
|
||||
// given: Config with legacy omo_agent key
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: omo_agent should be migrated to sisyphus_agent
|
||||
// then: omo_agent should be migrated to sisyphus_agent
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates legacy agent names in agents object", () => {
|
||||
// #given: Config with legacy agent names
|
||||
// given: Config with legacy agent names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
@@ -329,32 +329,32 @@ describe("migrateConfigFile", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Agent names should be migrated
|
||||
// then: Agent names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["sisyphus"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("migrates legacy hook names in disabled_hooks", () => {
|
||||
// #given: Config with legacy hook names
|
||||
// given: Config with legacy hook names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
disabled_hooks: ["anthropic-auto-compact", "comment-checker"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Hook names should be migrated
|
||||
// then: Hook names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact")
|
||||
})
|
||||
|
||||
test("does not write if no migration needed", () => {
|
||||
// #given: Config with current names
|
||||
// given: Config with current names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
sisyphus_agent: { disabled: false },
|
||||
agents: {
|
||||
@@ -363,15 +363,15 @@ describe("migrateConfigFile", () => {
|
||||
disabled_hooks: ["anthropic-context-window-limit-recovery"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: No write should be needed
|
||||
// then: No write should be needed
|
||||
expect(needsWrite).toBe(false)
|
||||
})
|
||||
|
||||
test("handles migration of all legacy items together", () => {
|
||||
// #given: Config with all legacy items
|
||||
// given: Config with all legacy items
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
agents: {
|
||||
@@ -381,10 +381,10 @@ describe("migrateConfigFile", () => {
|
||||
disabled_hooks: ["anthropic-auto-compact"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: All legacy items should be migrated
|
||||
// then: All legacy items should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
@@ -397,8 +397,8 @@ describe("migrateConfigFile", () => {
|
||||
|
||||
describe("migration maps", () => {
|
||||
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
|
||||
// #given/#when: Check AGENT_NAME_MAP
|
||||
// #then: Should contain all legacy → lowercase mappings
|
||||
// given/#when: Check AGENT_NAME_MAP
|
||||
// then: Should contain all legacy → lowercase mappings
|
||||
expect(AGENT_NAME_MAP["omo"]).toBe("sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO"]).toBe("sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("prometheus")
|
||||
@@ -408,25 +408,25 @@ describe("migration maps", () => {
|
||||
})
|
||||
|
||||
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
|
||||
// #given/#when: Check HOOK_NAME_MAP
|
||||
// #then: Should contain be legacy hook name mapping
|
||||
// given/#when: Check HOOK_NAME_MAP
|
||||
// then: Should contain be legacy hook name mapping
|
||||
expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateAgentConfigToCategory", () => {
|
||||
test("migrates model to category when mapping exists", () => {
|
||||
// #given: Config with a model that has a category mapping
|
||||
// given: Config with a model that has a category mapping
|
||||
const config = {
|
||||
model: "google/gemini-3-pro",
|
||||
temperature: 0.5,
|
||||
top_p: 0.9,
|
||||
}
|
||||
|
||||
// #when: Migrate agent config to category
|
||||
// when: Migrate agent config to category
|
||||
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
||||
|
||||
// #then: Model should be replaced with category
|
||||
// then: Model should be replaced with category
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated.category).toBe("visual-engineering")
|
||||
expect(migrated.model).toBeUndefined()
|
||||
@@ -435,37 +435,37 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
})
|
||||
|
||||
test("does not migrate when model is not in map", () => {
|
||||
// #given: Config with a model that has no mapping
|
||||
// given: Config with a model that has no mapping
|
||||
const config = {
|
||||
model: "custom/model",
|
||||
temperature: 0.5,
|
||||
}
|
||||
|
||||
// #when: Migrate agent config to category
|
||||
// when: Migrate agent config to category
|
||||
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
||||
|
||||
// #then: Config should remain unchanged
|
||||
// then: Config should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual(config)
|
||||
})
|
||||
|
||||
test("does not migrate when model is not a string", () => {
|
||||
// #given: Config with non-string model
|
||||
// given: Config with non-string model
|
||||
const config = {
|
||||
model: { name: "test" },
|
||||
temperature: 0.5,
|
||||
}
|
||||
|
||||
// #when: Migrate agent config to category
|
||||
// when: Migrate agent config to category
|
||||
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
||||
|
||||
// #then: Config should remain unchanged
|
||||
// then: Config should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual(config)
|
||||
})
|
||||
|
||||
test("handles all mapped models correctly", () => {
|
||||
// #given: Configs for each mapped model
|
||||
// given: Configs for each mapped model
|
||||
const configs = [
|
||||
{ model: "google/gemini-3-pro" },
|
||||
{ model: "google/gemini-3-flash" },
|
||||
@@ -477,10 +477,10 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
|
||||
const expectedCategories = ["visual-engineering", "writing", "ultrabrain", "quick", "unspecified-high", "unspecified-low"]
|
||||
|
||||
// #when: Migrate each config
|
||||
// when: Migrate each config
|
||||
const results = configs.map(migrateAgentConfigToCategory)
|
||||
|
||||
// #then: Each model should map to correct category
|
||||
// then: Each model should map to correct category
|
||||
results.forEach((result, index) => {
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.migrated.category).toBe(expectedCategories[index])
|
||||
@@ -489,7 +489,7 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
})
|
||||
|
||||
test("preserves non-model fields during migration", () => {
|
||||
// #given: Config with multiple fields
|
||||
// given: Config with multiple fields
|
||||
const config = {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.1,
|
||||
@@ -498,10 +498,10 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
prompt_append: "custom instruction",
|
||||
}
|
||||
|
||||
// #when: Migrate agent config to category
|
||||
// when: Migrate agent config to category
|
||||
const { migrated } = migrateAgentConfigToCategory(config)
|
||||
|
||||
// #then: All non-model fields should be preserved
|
||||
// then: All non-model fields should be preserved
|
||||
expect(migrated.category).toBe("ultrabrain")
|
||||
expect(migrated.temperature).toBe(0.1)
|
||||
expect(migrated.top_p).toBe(0.95)
|
||||
@@ -512,57 +512,57 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
|
||||
describe("shouldDeleteAgentConfig", () => {
|
||||
test("returns true when config only has category field", () => {
|
||||
// #given: Config with only category field (no overrides)
|
||||
// given: Config with only category field (no overrides)
|
||||
const config = { category: "visual-engineering" }
|
||||
|
||||
// #when: Check if config should be deleted
|
||||
// when: Check if config should be deleted
|
||||
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
||||
|
||||
// #then: Should return true (matches category defaults)
|
||||
// then: Should return true (matches category defaults)
|
||||
expect(shouldDelete).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when category does not exist", () => {
|
||||
// #given: Config with unknown category
|
||||
// given: Config with unknown category
|
||||
const config = { category: "unknown" }
|
||||
|
||||
// #when: Check if config should be deleted
|
||||
// when: Check if config should be deleted
|
||||
const shouldDelete = shouldDeleteAgentConfig(config, "unknown")
|
||||
|
||||
// #then: Should return false (category not found)
|
||||
// then: Should return false (category not found)
|
||||
expect(shouldDelete).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when all fields match category defaults", () => {
|
||||
// #given: Config with fields matching category defaults
|
||||
// given: Config with fields matching category defaults
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
model: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when: Check if config should be deleted
|
||||
// when: Check if config should be deleted
|
||||
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
||||
|
||||
// #then: Should return true (all fields match defaults)
|
||||
// then: Should return true (all fields match defaults)
|
||||
expect(shouldDelete).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when fields differ from category defaults", () => {
|
||||
// #given: Config with custom model override
|
||||
// given: Config with custom model override
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
}
|
||||
|
||||
// #when: Check if config should be deleted
|
||||
// when: Check if config should be deleted
|
||||
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
||||
|
||||
// #then: Should return false (has custom override)
|
||||
// then: Should return false (has custom override)
|
||||
expect(shouldDelete).toBe(false)
|
||||
})
|
||||
|
||||
test("handles different categories with their defaults", () => {
|
||||
// #given: Configs for different categories
|
||||
// given: Configs for different categories
|
||||
const configs = [
|
||||
{ category: "ultrabrain" },
|
||||
{ category: "quick" },
|
||||
@@ -570,32 +570,32 @@ describe("shouldDeleteAgentConfig", () => {
|
||||
{ category: "unspecified-low" },
|
||||
]
|
||||
|
||||
// #when: Check each config
|
||||
// when: Check each config
|
||||
const results = configs.map((config) => shouldDeleteAgentConfig(config, config.category as string))
|
||||
|
||||
// #then: All should be true (all match defaults)
|
||||
// then: All should be true (all match defaults)
|
||||
results.forEach((result) => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("returns false when additional fields are present", () => {
|
||||
// #given: Config with extra fields
|
||||
// given: Config with extra fields
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
temperature: 0.7,
|
||||
custom_field: "value", // Extra field not in defaults
|
||||
}
|
||||
|
||||
// #when: Check if config should be deleted
|
||||
// when: Check if config should be deleted
|
||||
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
||||
|
||||
// #then: Should return false (has extra field)
|
||||
// then: Should return false (has extra field)
|
||||
expect(shouldDelete).toBe(false)
|
||||
})
|
||||
|
||||
test("handles complex config with multiple overrides", () => {
|
||||
// #given: Config with multiple custom overrides
|
||||
// given: Config with multiple custom overrides
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
temperature: 0.5, // Different from default
|
||||
@@ -603,10 +603,10 @@ describe("shouldDeleteAgentConfig", () => {
|
||||
prompt_append: "custom prompt", // Custom field
|
||||
}
|
||||
|
||||
// #when: Check if config should be deleted
|
||||
// when: Check if config should be deleted
|
||||
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
||||
|
||||
// #then: Should return false (has overrides)
|
||||
// then: Should return false (has overrides)
|
||||
expect(shouldDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -624,7 +624,7 @@ describe("migrateConfigFile with backup", () => {
|
||||
})
|
||||
|
||||
test("creates backup file with timestamp when legacy migration needed", () => {
|
||||
// #given: Config file path with legacy agent names needing migration
|
||||
// given: Config file path with legacy agent names needing migration
|
||||
const testConfigPath = "/tmp/test-config-migration.json"
|
||||
const testConfigContent = globalThis.JSON.stringify({ agents: { omo: { model: "test" } } }, null, 2)
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
@@ -636,10 +636,10 @@ describe("migrateConfigFile with backup", () => {
|
||||
fs.writeFileSync(testConfigPath, testConfigContent)
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Backup file should be created with timestamp
|
||||
// then: Backup file should be created with timestamp
|
||||
expect(needsWrite).toBe(true)
|
||||
|
||||
const dir = path.dirname(testConfigPath)
|
||||
@@ -659,7 +659,7 @@ describe("migrateConfigFile with backup", () => {
|
||||
})
|
||||
|
||||
test("preserves model setting without auto-conversion to category", () => {
|
||||
// #given: Config with model setting (should NOT be converted to category)
|
||||
// given: Config with model setting (should NOT be converted to category)
|
||||
const testConfigPath = "/tmp/test-config-preserve-model.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
@@ -672,10 +672,10 @@ describe("migrateConfigFile with backup", () => {
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: No migration needed - model settings should be preserved as-is
|
||||
// then: No migration needed - model settings should be preserved as-is
|
||||
expect(needsWrite).toBe(false)
|
||||
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
@@ -685,7 +685,7 @@ describe("migrateConfigFile with backup", () => {
|
||||
})
|
||||
|
||||
test("preserves category setting when explicitly set", () => {
|
||||
// #given: Config with explicit category setting
|
||||
// given: Config with explicit category setting
|
||||
const testConfigPath = "/tmp/test-config-preserve-category.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
@@ -697,10 +697,10 @@ describe("migrateConfigFile with backup", () => {
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: No migration needed - category settings should be preserved as-is
|
||||
// then: No migration needed - category settings should be preserved as-is
|
||||
expect(needsWrite).toBe(false)
|
||||
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
@@ -709,7 +709,7 @@ describe("migrateConfigFile with backup", () => {
|
||||
})
|
||||
|
||||
test("does not write when no migration needed", () => {
|
||||
// #given: Config with no migrations needed
|
||||
// given: Config with no migrations needed
|
||||
const testConfigPath = "/tmp/test-config-no-migration.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
@@ -734,10 +734,10 @@ describe("migrateConfigFile with backup", () => {
|
||||
}
|
||||
})
|
||||
|
||||
// #when: Migrate config file
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Should not write or create backup
|
||||
// then: Should not write or create backup
|
||||
expect(needsWrite).toBe(false)
|
||||
|
||||
const files = fs.readdirSync(dir)
|
||||
|
||||
@@ -153,9 +153,9 @@ describe("fetchAvailableModels", () => {
|
||||
})
|
||||
|
||||
describe("fuzzyMatchModel", () => {
|
||||
// #given available models from multiple providers
|
||||
// #when searching for a substring match
|
||||
// #then return the matching model
|
||||
// given available models from multiple providers
|
||||
// when searching for a substring match
|
||||
// then return the matching model
|
||||
it("should match substring in model name", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -166,9 +166,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// #given available model with preview suffix
|
||||
// #when searching with provider-prefixed base model
|
||||
// #then return preview model
|
||||
// given available model with preview suffix
|
||||
// when searching with provider-prefixed base model
|
||||
// then return preview model
|
||||
it("should match preview suffix for gemini-3-flash", () => {
|
||||
const available = new Set(["google/gemini-3-flash-preview"])
|
||||
const result = fuzzyMatchModel(
|
||||
@@ -179,9 +179,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("google/gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
// #given available models with partial matches
|
||||
// #when searching for a substring
|
||||
// #then return exact match if it exists
|
||||
// given available models with partial matches
|
||||
// when searching for a substring
|
||||
// then return exact match if it exists
|
||||
it("should prefer exact match over substring match", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -192,9 +192,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// #given available models with multiple substring matches
|
||||
// #when searching for a substring
|
||||
// #then return the shorter model name (more specific)
|
||||
// given available models with multiple substring matches
|
||||
// when searching for a substring
|
||||
// then return the shorter model name (more specific)
|
||||
it("should prefer shorter model name when multiple matches exist", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2-ultra",
|
||||
@@ -204,9 +204,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2-ultra")
|
||||
})
|
||||
|
||||
// #given available models with claude variants
|
||||
// #when searching for claude-opus
|
||||
// #then return matching claude-opus model
|
||||
// given available models with claude variants
|
||||
// when searching for claude-opus
|
||||
// then return matching claude-opus model
|
||||
it("should match claude-opus to claude-opus-4-5", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
@@ -216,9 +216,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
// #given available models from multiple providers
|
||||
// #when providers filter is specified
|
||||
// #then only search models from specified providers
|
||||
// given available models from multiple providers
|
||||
// when providers filter is specified
|
||||
// then only search models from specified providers
|
||||
it("should filter by provider when providers array is given", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -229,9 +229,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// #given available models from multiple providers
|
||||
// #when providers filter excludes matching models
|
||||
// #then return null
|
||||
// given available models from multiple providers
|
||||
// when providers filter excludes matching models
|
||||
// then return null
|
||||
it("should return null when provider filter excludes all matches", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -241,9 +241,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// #given available models
|
||||
// #when no substring match exists
|
||||
// #then return null
|
||||
// given available models
|
||||
// when no substring match exists
|
||||
// then return null
|
||||
it("should return null when no match found", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -253,9 +253,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// #given available models with different cases
|
||||
// #when searching with different case
|
||||
// #then match case-insensitively
|
||||
// given available models with different cases
|
||||
// when searching with different case
|
||||
// then match case-insensitively
|
||||
it("should match case-insensitively", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -265,9 +265,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// #given available models with exact match and longer variants
|
||||
// #when searching for exact match
|
||||
// #then return exact match first
|
||||
// given available models with exact match and longer variants
|
||||
// when searching for exact match
|
||||
// then return exact match first
|
||||
it("should prioritize exact match over longer variants", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
@@ -277,9 +277,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
// #given available models with multiple providers
|
||||
// #when multiple providers are specified
|
||||
// #then search all specified providers
|
||||
// given available models with multiple providers
|
||||
// when multiple providers are specified
|
||||
// then search all specified providers
|
||||
it("should search all specified providers", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -290,9 +290,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// #given available models with provider prefix
|
||||
// #when searching with provider filter
|
||||
// #then only match models with correct provider prefix
|
||||
// given available models with provider prefix
|
||||
// when searching with provider filter
|
||||
// then only match models with correct provider prefix
|
||||
it("should only match models with correct provider prefix", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -302,9 +302,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// #given empty available set
|
||||
// #when searching
|
||||
// #then return null
|
||||
// given empty available set
|
||||
// when searching
|
||||
// then return null
|
||||
it("should return null for empty available set", () => {
|
||||
const available = new Set<string>()
|
||||
const result = fuzzyMatchModel("gpt", available)
|
||||
@@ -313,9 +313,9 @@ describe("fuzzyMatchModel", () => {
|
||||
})
|
||||
|
||||
describe("getConnectedProviders", () => {
|
||||
//#given SDK client with connected providers
|
||||
//#when provider.list returns data
|
||||
//#then returns connected array
|
||||
// given SDK client with connected providers
|
||||
// when provider.list returns data
|
||||
// then returns connected array
|
||||
it("should return connected providers from SDK", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
@@ -330,9 +330,9 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual(["anthropic", "opencode", "google"])
|
||||
})
|
||||
|
||||
//#given SDK client
|
||||
//#when provider.list throws error
|
||||
//#then returns empty array
|
||||
// given SDK client
|
||||
// when provider.list throws error
|
||||
// then returns empty array
|
||||
it("should return empty array on SDK error", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
@@ -345,9 +345,9 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
//#given SDK client with empty connected array
|
||||
//#when provider.list returns empty
|
||||
//#then returns empty array
|
||||
// given SDK client with empty connected array
|
||||
// when provider.list returns empty
|
||||
// then returns empty array
|
||||
it("should return empty array when no providers connected", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
@@ -360,9 +360,9 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
//#given SDK client without provider.list method
|
||||
//#when getConnectedProviders called
|
||||
//#then returns empty array
|
||||
// given SDK client without provider.list method
|
||||
// when getConnectedProviders called
|
||||
// then returns empty array
|
||||
it("should return empty array when client.provider.list not available", async () => {
|
||||
const mockClient = {}
|
||||
|
||||
@@ -371,18 +371,18 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
//#given null client
|
||||
//#when getConnectedProviders called
|
||||
//#then returns empty array
|
||||
// given null client
|
||||
// when getConnectedProviders called
|
||||
// then returns empty array
|
||||
it("should return empty array for null client", async () => {
|
||||
const result = await getConnectedProviders(null)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
//#given SDK client with missing data.connected
|
||||
//#when provider.list returns without connected field
|
||||
//#then returns empty array
|
||||
// given SDK client with missing data.connected
|
||||
// when provider.list returns without connected field
|
||||
// then returns empty array
|
||||
it("should return empty array when data.connected is undefined", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
@@ -422,9 +422,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
|
||||
//#given cache with multiple providers
|
||||
//#when connectedProviders specifies one provider
|
||||
//#then only returns models from that provider
|
||||
// given cache with multiple providers
|
||||
// when connectedProviders specifies one provider
|
||||
// then only returns models from that provider
|
||||
it("should filter models by connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -442,9 +442,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
|
||||
//#given cache with multiple providers
|
||||
//#when connectedProviders specifies multiple providers
|
||||
//#then returns models from all specified providers
|
||||
// given cache with multiple providers
|
||||
// when connectedProviders specifies multiple providers
|
||||
// then returns models from all specified providers
|
||||
it("should filter models by multiple connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -462,9 +462,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||
})
|
||||
|
||||
//#given cache with models
|
||||
//#when connectedProviders is empty array
|
||||
//#then returns empty set
|
||||
// given cache with models
|
||||
// when connectedProviders is empty array
|
||||
// then returns empty set
|
||||
it("should return empty set when connectedProviders is empty", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -478,9 +478,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
//#given cache with models
|
||||
//#when connectedProviders is undefined (no options)
|
||||
//#then returns empty set (triggers fallback in resolver)
|
||||
// given cache with models
|
||||
// when connectedProviders is undefined (no options)
|
||||
// then returns empty set (triggers fallback in resolver)
|
||||
it("should return empty set when connectedProviders not specified", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -492,9 +492,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
//#given cache with models
|
||||
//#when connectedProviders contains provider not in cache
|
||||
//#then returns empty set for that provider
|
||||
// given cache with models
|
||||
// when connectedProviders contains provider not in cache
|
||||
// then returns empty set for that provider
|
||||
it("should handle provider not in cache gracefully", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -507,9 +507,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
//#given cache with models and mixed connected providers
|
||||
//#when some providers exist in cache and some don't
|
||||
//#then returns models only from matching providers
|
||||
// given cache with models and mixed connected providers
|
||||
// when some providers exist in cache and some don't
|
||||
// then returns models only from matching providers
|
||||
it("should return models from providers that exist in both cache and connected list", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -524,9 +524,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
|
||||
})
|
||||
|
||||
//#given filtered fetch
|
||||
//#when called twice with different filters
|
||||
//#then does NOT use cache (dynamic per-session)
|
||||
// given filtered fetch
|
||||
// when called twice with different filters
|
||||
// then does NOT use cache (dynamic per-session)
|
||||
it("should not cache filtered results", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -547,9 +547,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result2.has("openai/gpt-5.2")).toBe(true)
|
||||
})
|
||||
|
||||
//#given connectedProviders unknown
|
||||
//#when called twice without connectedProviders
|
||||
//#then always returns empty set (triggers fallback)
|
||||
// given connectedProviders unknown
|
||||
// when called twice without connectedProviders
|
||||
// then always returns empty set (triggers fallback)
|
||||
it("should return empty set when connectedProviders unknown", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -598,9 +598,9 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
|
||||
//#given provider-models cache exists (whitelist-filtered)
|
||||
//#when fetchAvailableModels called
|
||||
//#then uses provider-models cache instead of models.json
|
||||
// given provider-models cache exists (whitelist-filtered)
|
||||
// when fetchAvailableModels called
|
||||
// then uses provider-models cache instead of models.json
|
||||
it("should prefer provider-models cache over models.json", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
@@ -626,9 +626,9 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(false)
|
||||
})
|
||||
|
||||
//#given provider-models cache exists but has no models (API failure)
|
||||
//#when fetchAvailableModels called
|
||||
//#then falls back to models.json so fuzzy matching can still work
|
||||
// given provider-models cache exists but has no models (API failure)
|
||||
// when fetchAvailableModels called
|
||||
// then falls back to models.json so fuzzy matching can still work
|
||||
it("should fall back to models.json when provider-models cache is empty", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
@@ -647,9 +647,9 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(match).toBe("google/gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
//#given only models.json exists (no provider-models cache)
|
||||
//#when fetchAvailableModels called
|
||||
//#then falls back to models.json (no whitelist filtering)
|
||||
// given only models.json exists (no provider-models cache)
|
||||
// when fetchAvailableModels called
|
||||
// then falls back to models.json (no whitelist filtering)
|
||||
it("should fallback to models.json when provider-models cache not found", async () => {
|
||||
writeModelsCache({
|
||||
opencode: { models: { "glm-4.7-free": {}, "gpt-5-nano": {}, "gpt-5.2": {} } },
|
||||
@@ -665,9 +665,9 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.has("opencode/gpt-5.2")).toBe(true)
|
||||
})
|
||||
|
||||
//#given provider-models cache with whitelist
|
||||
//#when connectedProviders filters to subset
|
||||
//#then only returns models from connected providers
|
||||
// given provider-models cache with whitelist
|
||||
// when connectedProviders filters to subset
|
||||
// then only returns models from connected providers
|
||||
it("should filter by connectedProviders even with provider-models cache", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
@@ -691,35 +691,35 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
|
||||
describe("isModelAvailable", () => {
|
||||
it("returns true when model exists via fuzzy match", () => {
|
||||
// #given
|
||||
// given
|
||||
const available = new Set(["openai/gpt-5.2-codex", "anthropic/claude-opus-4-5"])
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.2-codex", available)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when model not found", () => {
|
||||
// #given
|
||||
// given
|
||||
const available = new Set(["anthropic/claude-opus-4-5"])
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.2-codex", available)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for empty available set", () => {
|
||||
// #given
|
||||
// given
|
||||
const available = new Set<string>()
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.2-codex", available)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
|
||||
describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
test("oracle has valid fallbackChain with gpt-5.2 as primary", () => {
|
||||
// #given - oracle agent requirement
|
||||
// given - oracle agent requirement
|
||||
const oracle = AGENT_MODEL_REQUIREMENTS["oracle"]
|
||||
|
||||
// #when - accessing oracle requirement
|
||||
// #then - fallbackChain exists with gpt-5.2 as first entry
|
||||
// when - accessing oracle requirement
|
||||
// then - fallbackChain exists with gpt-5.2 as first entry
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle.fallbackChain).toBeArray()
|
||||
expect(oracle.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -24,11 +24,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("sisyphus has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - sisyphus agent requirement
|
||||
// given - sisyphus agent requirement
|
||||
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
// #when - accessing Sisyphus requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// when - accessing Sisyphus requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus.fallbackChain).toBeArray()
|
||||
expect(sisyphus.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -40,11 +40,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("librarian has valid fallbackChain with glm-4.7 as primary", () => {
|
||||
// #given - librarian agent requirement
|
||||
// given - librarian agent requirement
|
||||
const librarian = AGENT_MODEL_REQUIREMENTS["librarian"]
|
||||
|
||||
// #when - accessing librarian requirement
|
||||
// #then - fallbackChain exists with glm-4.7 as first entry
|
||||
// when - accessing librarian requirement
|
||||
// then - fallbackChain exists with glm-4.7 as first entry
|
||||
expect(librarian).toBeDefined()
|
||||
expect(librarian.fallbackChain).toBeArray()
|
||||
expect(librarian.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -55,11 +55,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("explore has valid fallbackChain with claude-haiku-4-5 as primary", () => {
|
||||
// #given - explore agent requirement
|
||||
// given - explore agent requirement
|
||||
const explore = AGENT_MODEL_REQUIREMENTS["explore"]
|
||||
|
||||
// #when - accessing explore requirement
|
||||
// #then - fallbackChain exists with claude-haiku-4-5 as first entry, gpt-5-mini as second, gpt-5-nano as third
|
||||
// when - accessing explore requirement
|
||||
// then - fallbackChain exists with claude-haiku-4-5 as first entry, gpt-5-mini as second, gpt-5-nano as third
|
||||
expect(explore).toBeDefined()
|
||||
expect(explore.fallbackChain).toBeArray()
|
||||
expect(explore.fallbackChain).toHaveLength(3)
|
||||
@@ -79,11 +79,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("multimodal-looker has valid fallbackChain with gemini-3-flash as primary", () => {
|
||||
// #given - multimodal-looker agent requirement
|
||||
// given - multimodal-looker agent requirement
|
||||
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
|
||||
|
||||
// #when - accessing multimodal-looker requirement
|
||||
// #then - fallbackChain exists with gemini-3-flash as first entry
|
||||
// when - accessing multimodal-looker requirement
|
||||
// then - fallbackChain exists with gemini-3-flash as first entry
|
||||
expect(multimodalLooker).toBeDefined()
|
||||
expect(multimodalLooker.fallbackChain).toBeArray()
|
||||
expect(multimodalLooker.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -94,11 +94,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("prometheus has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - prometheus agent requirement
|
||||
// given - prometheus agent requirement
|
||||
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
|
||||
|
||||
// #when - accessing Prometheus requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// when - accessing Prometheus requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
expect(prometheus).toBeDefined()
|
||||
expect(prometheus.fallbackChain).toBeArray()
|
||||
expect(prometheus.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -110,11 +110,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("metis has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - metis agent requirement
|
||||
// given - metis agent requirement
|
||||
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
|
||||
|
||||
// #when - accessing Metis requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// when - accessing Metis requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
expect(metis).toBeDefined()
|
||||
expect(metis.fallbackChain).toBeArray()
|
||||
expect(metis.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -126,11 +126,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("momus has valid fallbackChain with gpt-5.2 as primary", () => {
|
||||
// #given - momus agent requirement
|
||||
// given - momus agent requirement
|
||||
const momus = AGENT_MODEL_REQUIREMENTS["momus"]
|
||||
|
||||
// #when - accessing Momus requirement
|
||||
// #then - fallbackChain exists with gpt-5.2 as first entry, variant medium
|
||||
// when - accessing Momus requirement
|
||||
// then - fallbackChain exists with gpt-5.2 as first entry, variant medium
|
||||
expect(momus).toBeDefined()
|
||||
expect(momus.fallbackChain).toBeArray()
|
||||
expect(momus.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -142,11 +142,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("atlas has valid fallbackChain with k2p5 as primary (kimi-for-coding prioritized)", () => {
|
||||
// #given - atlas agent requirement
|
||||
// given - atlas agent requirement
|
||||
const atlas = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
// #when - accessing Atlas requirement
|
||||
// #then - fallbackChain exists with k2p5 as first entry (kimi-for-coding prioritized)
|
||||
// when - accessing Atlas requirement
|
||||
// then - fallbackChain exists with k2p5 as first entry (kimi-for-coding prioritized)
|
||||
expect(atlas).toBeDefined()
|
||||
expect(atlas.fallbackChain).toBeArray()
|
||||
expect(atlas.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -157,7 +157,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("all 9 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 9 agent names
|
||||
// given - list of 9 agent names
|
||||
const expectedAgents = [
|
||||
"sisyphus",
|
||||
"oracle",
|
||||
@@ -170,10 +170,10 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
"atlas",
|
||||
]
|
||||
|
||||
// #when - checking AGENT_MODEL_REQUIREMENTS
|
||||
// when - checking AGENT_MODEL_REQUIREMENTS
|
||||
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all agents present with valid fallbackChain
|
||||
// then - all agents present with valid fallbackChain
|
||||
expect(definedAgents).toHaveLength(9)
|
||||
for (const agent of expectedAgents) {
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
|
||||
@@ -193,11 +193,11 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
test("ultrabrain has valid fallbackChain with gpt-5.2-codex as primary", () => {
|
||||
// #given - ultrabrain category requirement
|
||||
// given - ultrabrain category requirement
|
||||
const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"]
|
||||
|
||||
// #when - accessing ultrabrain requirement
|
||||
// #then - fallbackChain exists with gpt-5.2-codex as first entry
|
||||
// when - accessing ultrabrain requirement
|
||||
// then - fallbackChain exists with gpt-5.2-codex as first entry
|
||||
expect(ultrabrain).toBeDefined()
|
||||
expect(ultrabrain.fallbackChain).toBeArray()
|
||||
expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -209,11 +209,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("deep has valid fallbackChain with gpt-5.2-codex as primary", () => {
|
||||
// #given - deep category requirement
|
||||
// given - deep category requirement
|
||||
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
|
||||
|
||||
// #when - accessing deep requirement
|
||||
// #then - fallbackChain exists with gpt-5.2-codex as first entry, medium variant
|
||||
// when - accessing deep requirement
|
||||
// then - fallbackChain exists with gpt-5.2-codex as first entry, medium variant
|
||||
expect(deep).toBeDefined()
|
||||
expect(deep.fallbackChain).toBeArray()
|
||||
expect(deep.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -225,11 +225,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("visual-engineering has valid fallbackChain with gemini-3-pro as primary", () => {
|
||||
// #given - visual-engineering category requirement
|
||||
// given - visual-engineering category requirement
|
||||
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
||||
|
||||
// #when - accessing visual-engineering requirement
|
||||
// #then - fallbackChain exists with gemini-3-pro as first entry
|
||||
// when - accessing visual-engineering requirement
|
||||
// then - fallbackChain exists with gemini-3-pro as first entry
|
||||
expect(visualEngineering).toBeDefined()
|
||||
expect(visualEngineering.fallbackChain).toBeArray()
|
||||
expect(visualEngineering.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -240,11 +240,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => {
|
||||
// #given - quick category requirement
|
||||
// given - quick category requirement
|
||||
const quick = CATEGORY_MODEL_REQUIREMENTS["quick"]
|
||||
|
||||
// #when - accessing quick requirement
|
||||
// #then - fallbackChain exists with claude-haiku-4-5 as first entry
|
||||
// when - accessing quick requirement
|
||||
// then - fallbackChain exists with claude-haiku-4-5 as first entry
|
||||
expect(quick).toBeDefined()
|
||||
expect(quick.fallbackChain).toBeArray()
|
||||
expect(quick.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -255,11 +255,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("unspecified-low has valid fallbackChain with claude-sonnet-4-5 as primary", () => {
|
||||
// #given - unspecified-low category requirement
|
||||
// given - unspecified-low category requirement
|
||||
const unspecifiedLow = CATEGORY_MODEL_REQUIREMENTS["unspecified-low"]
|
||||
|
||||
// #when - accessing unspecified-low requirement
|
||||
// #then - fallbackChain exists with claude-sonnet-4-5 as first entry
|
||||
// when - accessing unspecified-low requirement
|
||||
// then - fallbackChain exists with claude-sonnet-4-5 as first entry
|
||||
expect(unspecifiedLow).toBeDefined()
|
||||
expect(unspecifiedLow.fallbackChain).toBeArray()
|
||||
expect(unspecifiedLow.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -270,11 +270,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("unspecified-high has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// #given - unspecified-high category requirement
|
||||
// given - unspecified-high category requirement
|
||||
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
|
||||
|
||||
// #when - accessing unspecified-high requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// when - accessing unspecified-high requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
expect(unspecifiedHigh).toBeDefined()
|
||||
expect(unspecifiedHigh.fallbackChain).toBeArray()
|
||||
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -286,11 +286,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("artistry has valid fallbackChain with gemini-3-pro as primary", () => {
|
||||
// #given - artistry category requirement
|
||||
// given - artistry category requirement
|
||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||
|
||||
// #when - accessing artistry requirement
|
||||
// #then - fallbackChain exists with gemini-3-pro as first entry
|
||||
// when - accessing artistry requirement
|
||||
// then - fallbackChain exists with gemini-3-pro as first entry
|
||||
expect(artistry).toBeDefined()
|
||||
expect(artistry.fallbackChain).toBeArray()
|
||||
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -302,11 +302,11 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
|
||||
// #given - writing category requirement
|
||||
// given - writing category requirement
|
||||
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
||||
|
||||
// #when - accessing writing requirement
|
||||
// #then - fallbackChain exists with gemini-3-flash as first entry
|
||||
// when - accessing writing requirement
|
||||
// then - fallbackChain exists with gemini-3-flash as first entry
|
||||
expect(writing).toBeDefined()
|
||||
expect(writing.fallbackChain).toBeArray()
|
||||
expect(writing.fallbackChain.length).toBeGreaterThan(0)
|
||||
@@ -317,7 +317,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
test("all 8 categories have valid fallbackChain arrays", () => {
|
||||
// #given - list of 8 category names
|
||||
// given - list of 8 category names
|
||||
const expectedCategories = [
|
||||
"visual-engineering",
|
||||
"ultrabrain",
|
||||
@@ -329,10 +329,10 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
"writing",
|
||||
]
|
||||
|
||||
// #when - checking CATEGORY_MODEL_REQUIREMENTS
|
||||
// when - checking CATEGORY_MODEL_REQUIREMENTS
|
||||
const definedCategories = Object.keys(CATEGORY_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all categories present with valid fallbackChain
|
||||
// then - all categories present with valid fallbackChain
|
||||
expect(definedCategories).toHaveLength(8)
|
||||
for (const category of expectedCategories) {
|
||||
const requirement = CATEGORY_MODEL_REQUIREMENTS[category]
|
||||
@@ -352,36 +352,36 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
describe("FallbackEntry type", () => {
|
||||
test("FallbackEntry structure is correct", () => {
|
||||
// #given - a valid FallbackEntry object
|
||||
// given - a valid FallbackEntry object
|
||||
const entry: FallbackEntry = {
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-5",
|
||||
variant: "high",
|
||||
}
|
||||
|
||||
// #when - accessing properties
|
||||
// #then - all properties are accessible
|
||||
// when - accessing properties
|
||||
// then - all properties are accessible
|
||||
expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(entry.model).toBe("claude-opus-4-5")
|
||||
expect(entry.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("FallbackEntry variant is optional", () => {
|
||||
// #given - a FallbackEntry without variant
|
||||
// given - a FallbackEntry without variant
|
||||
const entry: FallbackEntry = {
|
||||
providers: ["opencode", "anthropic"],
|
||||
model: "glm-4.7-free",
|
||||
}
|
||||
|
||||
// #when - accessing variant
|
||||
// #then - variant is undefined
|
||||
// when - accessing variant
|
||||
// then - variant is undefined
|
||||
expect(entry.variant).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ModelRequirement type", () => {
|
||||
test("ModelRequirement structure with fallbackChain is correct", () => {
|
||||
// #given - a valid ModelRequirement object
|
||||
// given - a valid ModelRequirement object
|
||||
const requirement: ModelRequirement = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5", variant: "max" },
|
||||
@@ -389,8 +389,8 @@ describe("ModelRequirement type", () => {
|
||||
],
|
||||
}
|
||||
|
||||
// #when - accessing properties
|
||||
// #then - fallbackChain is accessible with correct structure
|
||||
// when - accessing properties
|
||||
// then - fallbackChain is accessible with correct structure
|
||||
expect(requirement.fallbackChain).toBeArray()
|
||||
expect(requirement.fallbackChain).toHaveLength(2)
|
||||
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-5")
|
||||
@@ -398,25 +398,25 @@ describe("ModelRequirement type", () => {
|
||||
})
|
||||
|
||||
test("ModelRequirement variant is optional", () => {
|
||||
// #given - a ModelRequirement without top-level variant
|
||||
// given - a ModelRequirement without top-level variant
|
||||
const requirement: ModelRequirement = {
|
||||
fallbackChain: [{ providers: ["opencode"], model: "glm-4.7-free" }],
|
||||
}
|
||||
|
||||
// #when - accessing variant
|
||||
// #then - variant is undefined
|
||||
// when - accessing variant
|
||||
// then - variant is undefined
|
||||
expect(requirement.variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("no model in fallbackChain has provider prefix", () => {
|
||||
// #given - all agent and category requirements
|
||||
// given - all agent and category requirements
|
||||
const allRequirements = [
|
||||
...Object.values(AGENT_MODEL_REQUIREMENTS),
|
||||
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
|
||||
]
|
||||
|
||||
// #when - checking each model in fallbackChain
|
||||
// #then - none contain "/" (provider prefix)
|
||||
// when - checking each model in fallbackChain
|
||||
// then - none contain "/" (provider prefix)
|
||||
for (const req of allRequirements) {
|
||||
for (const entry of req.fallbackChain) {
|
||||
expect(entry.model).not.toContain("/")
|
||||
@@ -425,14 +425,14 @@ describe("ModelRequirement type", () => {
|
||||
})
|
||||
|
||||
test("all fallbackChain entries have non-empty providers array", () => {
|
||||
// #given - all agent and category requirements
|
||||
// given - all agent and category requirements
|
||||
const allRequirements = [
|
||||
...Object.values(AGENT_MODEL_REQUIREMENTS),
|
||||
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
|
||||
]
|
||||
|
||||
// #when - checking each entry in fallbackChain
|
||||
// #then - all have non-empty providers array
|
||||
// when - checking each entry in fallbackChain
|
||||
// then - all have non-empty providers array
|
||||
for (const req of allRequirements) {
|
||||
for (const entry of req.fallbackChain) {
|
||||
expect(entry.providers).toBeArray()
|
||||
@@ -444,18 +444,18 @@ describe("ModelRequirement type", () => {
|
||||
|
||||
describe("requiresModel field in categories", () => {
|
||||
test("deep category has requiresModel set to gpt-5.2-codex", () => {
|
||||
// #given
|
||||
// given
|
||||
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
expect(deep.requiresModel).toBe("gpt-5.2-codex")
|
||||
})
|
||||
|
||||
test("artistry category has requiresModel set to gemini-3-pro", () => {
|
||||
// #given
|
||||
// given
|
||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
expect(artistry.requiresModel).toBe("gemini-3-pro")
|
||||
})
|
||||
})
|
||||
|
||||
+138
-138
@@ -6,97 +6,97 @@ import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
describe("resolveModel", () => {
|
||||
describe("priority chain", () => {
|
||||
test("returns userModel when all three are set", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("returns inheritedModel when userModel is undefined", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: undefined,
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
test("returns systemDefault when both userModel and inheritedModel are undefined", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: undefined,
|
||||
inheritedModel: undefined,
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("google/gemini-3-pro")
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty string handling", () => {
|
||||
test("treats empty string as unset, uses fallback", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
test("treats whitespace-only string as unset, uses fallback", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: " ",
|
||||
inheritedModel: "",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("google/gemini-3-pro")
|
||||
})
|
||||
})
|
||||
|
||||
describe("purity", () => {
|
||||
test("same input returns same output (referential transparency)", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result1 = resolveModel(input)
|
||||
const result2 = resolveModel(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
})
|
||||
@@ -115,7 +115,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("Step 1: UI Selection (highest priority)", () => {
|
||||
test("returns uiSelectedModel with override source when provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "opencode/glm-4.7-free",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
@@ -126,17 +126,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("opencode/glm-4.7-free")
|
||||
expect(result!.source).toBe("override")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/glm-4.7-free" })
|
||||
})
|
||||
|
||||
test("UI selection takes priority over config override", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "opencode/glm-4.7-free",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
@@ -144,16 +144,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("opencode/glm-4.7-free")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
test("whitespace-only uiSelectedModel is treated as not provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: " ",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
@@ -161,16 +161,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("empty string uiSelectedModel falls through to config override", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
@@ -178,17 +178,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Step 2: Config Override", () => {
|
||||
test("returns userModel with override source when userModel is provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
fallbackChain: [
|
||||
@@ -198,17 +198,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("override")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("override takes priority even if model not in availableModels", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "custom/my-model",
|
||||
fallbackChain: [
|
||||
@@ -218,16 +218,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("custom/my-model")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
test("whitespace-only userModel is treated as not provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: " ",
|
||||
fallbackChain: [
|
||||
@@ -237,15 +237,15 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.source).not.toBe("override")
|
||||
})
|
||||
|
||||
test("empty string userModel is treated as not provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "",
|
||||
fallbackChain: [
|
||||
@@ -255,17 +255,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.source).not.toBe("override")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Step 3: Provider fallback chain", () => {
|
||||
test("tries providers in order within entry and returns first match", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" },
|
||||
@@ -274,10 +274,10 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("github-copilot/claude-opus-4-5-preview")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
|
||||
@@ -289,7 +289,7 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
test("respects provider priority order within entry", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
||||
@@ -298,16 +298,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("openai/gpt-5.2")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("tries next provider when first provider has no match", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
||||
@@ -316,16 +316,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("opencode/gpt-5-nano")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("uses fuzzy matching within provider", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
||||
@@ -334,45 +334,45 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("skips fallback chain when not provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
|
||||
test("skips fallback chain when empty", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
|
||||
test("case-insensitive fuzzy matching", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
||||
@@ -381,16 +381,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("cross-provider fuzzy match when preferred provider unavailable (librarian scenario)", () => {
|
||||
// #given - glm-4.7 is defined for zai-coding-plan, but only opencode has it
|
||||
// given - glm-4.7 is defined for zai-coding-plan, but only opencode has it
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
@@ -400,10 +400,10 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should find glm-4.7 from opencode via cross-provider fuzzy match
|
||||
// then - should find glm-4.7 from opencode via cross-provider fuzzy match
|
||||
expect(result!.model).toBe("opencode/glm-4.7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
||||
@@ -414,7 +414,7 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
test("prefers specified provider over cross-provider match", () => {
|
||||
// #given - both zai-coding-plan and opencode have glm-4.7
|
||||
// given - both zai-coding-plan and opencode have glm-4.7
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
@@ -423,16 +423,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should prefer zai-coding-plan (specified provider) over opencode
|
||||
// then - should prefer zai-coding-plan (specified provider) over opencode
|
||||
expect(result!.model).toBe("zai-coding-plan/glm-4.7")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("cross-provider match preserves variant from entry", () => {
|
||||
// #given - entry has variant, model found via cross-provider
|
||||
// given - entry has variant, model found via cross-provider
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7", variant: "high" },
|
||||
@@ -441,16 +441,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - variant should be preserved
|
||||
// then - variant should be preserved
|
||||
expect(result!.model).toBe("opencode/glm-4.7")
|
||||
expect(result!.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("cross-provider match tries next entry if no match found anywhere", () => {
|
||||
// #given - first entry model not available anywhere, second entry available
|
||||
// given - first entry model not available anywhere, second entry available
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["zai-coding-plan"], model: "nonexistent-model" },
|
||||
@@ -460,10 +460,10 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should fall through to second entry
|
||||
// then - should fall through to second entry
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
@@ -471,7 +471,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("Step 4: System default fallback (no availability match)", () => {
|
||||
test("returns system default when no availability match found in fallback chain", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||
@@ -480,17 +480,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||
})
|
||||
|
||||
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
|
||||
// #given - both model cache and connected-providers cache are missing (first run)
|
||||
// given - both model cache and connected-providers cache are missing (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
@@ -500,16 +500,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: undefined, // no system default configured
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should return undefined to let OpenCode use Provider.defaultModel()
|
||||
// then - should return undefined to let OpenCode use Provider.defaultModel()
|
||||
expect(result).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("uses connected provider from fallback when availableModels empty but cache exists", () => {
|
||||
// #given - model cache missing but connected-providers cache exists
|
||||
// given - model cache missing but connected-providers cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
@@ -519,17 +519,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use connected provider (openai) from fallback chain
|
||||
// then - should use connected provider (openai) from fallback chain
|
||||
expect(result!.model).toBe("openai/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("uses github-copilot when google not connected (visual-engineering scenario)", () => {
|
||||
// #given - user has github-copilot but not google connected
|
||||
// given - user has github-copilot but not google connected
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
@@ -539,17 +539,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use github-copilot (second provider) since google not connected
|
||||
// then - should use github-copilot (second provider) since google not connected
|
||||
expect(result!.model).toBe("github-copilot/gemini-3-pro")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("falls through to system default when no provider in fallback is connected", () => {
|
||||
// #given - user only has quotio connected, but fallback chain has anthropic/opencode
|
||||
// given - user only has quotio connected, but fallback chain has anthropic/opencode
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["quotio"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
@@ -559,17 +559,17 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "quotio/claude-opus-4-5-20251101",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - no provider in fallback is connected, fall through to system default
|
||||
// then - no provider in fallback is connected, fall through to system default
|
||||
expect(result!.model).toBe("quotio/claude-opus-4-5-20251101")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
|
||||
// #given - no cache but system default is configured
|
||||
// given - no cache but system default is configured
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
@@ -579,26 +579,26 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should fall through to system default
|
||||
// then - should fall through to system default
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("returns system default when fallbackChain is not provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
@@ -606,10 +606,10 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("Multi-entry fallbackChain", () => {
|
||||
test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => {
|
||||
// #given
|
||||
// given
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-5"])
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
@@ -619,16 +619,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("tries all providers in first entry before moving to second entry", () => {
|
||||
// #given
|
||||
// given
|
||||
const availableModels = new Set(["google/gemini-3-pro"])
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
|
||||
@@ -638,19 +638,19 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("returns first matching entry even if later entries have better matches", () => {
|
||||
// #given
|
||||
// given
|
||||
const availableModels = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
])
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.2" },
|
||||
@@ -660,16 +660,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("openai/gpt-5.2")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("falls through to system default when none match availability", () => {
|
||||
// #given
|
||||
// given
|
||||
const availableModels = new Set(["other/model"])
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.2" },
|
||||
@@ -680,7 +680,7 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result!.model).toBe("system/default")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
@@ -688,17 +688,17 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("Type safety", () => {
|
||||
test("result has correct ModelResolutionResult shape", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result!.model).toBe("string")
|
||||
expect(["override", "provider-fallback", "system-default"]).toContain(result!.source)
|
||||
@@ -707,7 +707,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
||||
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
|
||||
// #given - gemini-3-pro is the category default, but only gemini-3-pro-preview is available
|
||||
// given - gemini-3-pro is the category default, but only gemini-3-pro-preview is available
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
fallbackChain: [
|
||||
@@ -717,16 +717,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should fuzzy match gemini-3-pro → gemini-3-pro-preview
|
||||
// then - should fuzzy match gemini-3-pro → gemini-3-pro-preview
|
||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
||||
expect(result!.source).toBe("category-default")
|
||||
})
|
||||
|
||||
test("categoryDefaultModel uses exact match when available", () => {
|
||||
// #given - exact match exists
|
||||
// given - exact match exists
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
fallbackChain: [
|
||||
@@ -736,16 +736,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use exact match
|
||||
// then - should use exact match
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("category-default")
|
||||
})
|
||||
|
||||
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
||||
// #given - categoryDefaultModel has no match, but fallbackChain does
|
||||
// given - categoryDefaultModel has no match, but fallbackChain does
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
fallbackChain: [
|
||||
@@ -755,16 +755,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should fall through to fallbackChain
|
||||
// then - should fall through to fallbackChain
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("userModel takes priority over categoryDefaultModel", () => {
|
||||
// #given - both userModel and categoryDefaultModel provided
|
||||
// given - both userModel and categoryDefaultModel provided
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
@@ -775,16 +775,16 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - userModel wins
|
||||
// then - userModel wins
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
test("categoryDefaultModel works when availableModels is empty but connected provider exists", () => {
|
||||
// #given - no availableModels but connected provider cache exists
|
||||
// given - no availableModels but connected provider cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
@@ -792,10 +792,10 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use categoryDefaultModel since google is connected
|
||||
// then - should use categoryDefaultModel since google is connected
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("category-default")
|
||||
cacheSpy.mockRestore()
|
||||
@@ -804,7 +804,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("Optional systemDefaultModel", () => {
|
||||
test("returns undefined when systemDefaultModel is undefined and no fallback found", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||
@@ -813,46 +813,46 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
test("still returns fallback match when systemDefaultModel undefined", () => {
|
||||
// #given
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
@@ -861,10 +861,10 @@ describe("resolveModelWithFallback", () => {
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parseModelSuggestion, promptWithModelSuggestionRetry } from "./model-su
|
||||
describe("parseModelSuggestion", () => {
|
||||
describe("structured NamedError format", () => {
|
||||
it("should extract suggestion from ProviderModelNotFoundError", () => {
|
||||
//#given a structured NamedError with suggestions
|
||||
// given a structured NamedError with suggestions
|
||||
const error = {
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
@@ -14,10 +14,10 @@ describe("parseModelSuggestion", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should return the first suggestion
|
||||
// then should return the first suggestion
|
||||
expect(result).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonet-4",
|
||||
@@ -26,7 +26,7 @@ describe("parseModelSuggestion", () => {
|
||||
})
|
||||
|
||||
it("should return null when suggestions array is empty", () => {
|
||||
//#given a NamedError with empty suggestions
|
||||
// given a NamedError with empty suggestions
|
||||
const error = {
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
@@ -36,15 +36,15 @@ describe("parseModelSuggestion", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null when suggestions field is missing", () => {
|
||||
//#given a NamedError without suggestions
|
||||
// given a NamedError without suggestions
|
||||
const error = {
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
@@ -53,17 +53,17 @@ describe("parseModelSuggestion", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("nested error format", () => {
|
||||
it("should extract suggestion from nested data.error", () => {
|
||||
//#given an error with nested NamedError in data field
|
||||
// given an error with nested NamedError in data field
|
||||
const error = {
|
||||
data: {
|
||||
name: "ProviderModelNotFoundError",
|
||||
@@ -75,10 +75,10 @@ describe("parseModelSuggestion", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should extract from nested structure
|
||||
// then should extract from nested structure
|
||||
expect(result).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
@@ -87,7 +87,7 @@ describe("parseModelSuggestion", () => {
|
||||
})
|
||||
|
||||
it("should extract suggestion from nested error field", () => {
|
||||
//#given an error with nested NamedError in error field
|
||||
// given an error with nested NamedError in error field
|
||||
const error = {
|
||||
error: {
|
||||
name: "ProviderModelNotFoundError",
|
||||
@@ -99,10 +99,10 @@ describe("parseModelSuggestion", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should extract from nested error field
|
||||
// then should extract from nested error field
|
||||
expect(result).toEqual({
|
||||
providerID: "google",
|
||||
modelID: "gemini-3-flsh",
|
||||
@@ -113,15 +113,15 @@ describe("parseModelSuggestion", () => {
|
||||
|
||||
describe("string message format", () => {
|
||||
it("should parse suggestion from error message string", () => {
|
||||
//#given an Error with model-not-found message and suggestion
|
||||
// given an Error with model-not-found message and suggestion
|
||||
const error = new Error(
|
||||
"Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4, claude-sonnet-4-5?"
|
||||
)
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should extract from message string
|
||||
// then should extract from message string
|
||||
expect(result).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonet-4",
|
||||
@@ -130,14 +130,14 @@ describe("parseModelSuggestion", () => {
|
||||
})
|
||||
|
||||
it("should parse from plain string error", () => {
|
||||
//#given a plain string error message
|
||||
// given a plain string error message
|
||||
const error =
|
||||
"Model not found: openai/gtp-5. Did you mean: gpt-5?"
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should extract from string
|
||||
// then should extract from string
|
||||
expect(result).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gtp-5",
|
||||
@@ -146,15 +146,15 @@ describe("parseModelSuggestion", () => {
|
||||
})
|
||||
|
||||
it("should parse from object with message property", () => {
|
||||
//#given an object with message property
|
||||
// given an object with message property
|
||||
const error = {
|
||||
message: "Model not found: google/gemini-3-flsh. Did you mean: gemini-3-flash?",
|
||||
}
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should extract from message property
|
||||
// then should extract from message property
|
||||
expect(result).toEqual({
|
||||
providerID: "google",
|
||||
modelID: "gemini-3-flsh",
|
||||
@@ -163,48 +163,48 @@ describe("parseModelSuggestion", () => {
|
||||
})
|
||||
|
||||
it("should return null when message has no suggestion", () => {
|
||||
//#given an error without Did you mean
|
||||
// given an error without Did you mean
|
||||
const error = new Error("Model not found: anthropic/nonexistent.")
|
||||
|
||||
//#when parsing the error
|
||||
// when parsing the error
|
||||
const result = parseModelSuggestion(error)
|
||||
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should return null for null error", () => {
|
||||
//#given null
|
||||
//#when parsing
|
||||
// given null
|
||||
// when parsing
|
||||
const result = parseModelSuggestion(null)
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for undefined error", () => {
|
||||
//#given undefined
|
||||
//#when parsing
|
||||
// given undefined
|
||||
// when parsing
|
||||
const result = parseModelSuggestion(undefined)
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for unrelated error", () => {
|
||||
//#given an unrelated error
|
||||
// given an unrelated error
|
||||
const error = new Error("Connection timeout")
|
||||
//#when parsing
|
||||
// when parsing
|
||||
const result = parseModelSuggestion(error)
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty object", () => {
|
||||
//#given empty object
|
||||
//#when parsing
|
||||
// given empty object
|
||||
// when parsing
|
||||
const result = parseModelSuggestion({})
|
||||
//#then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -212,11 +212,11 @@ describe("parseModelSuggestion", () => {
|
||||
|
||||
describe("promptWithModelSuggestionRetry", () => {
|
||||
it("should succeed on first try without retry", async () => {
|
||||
//#given a client where prompt succeeds
|
||||
// given a client where prompt succeeds
|
||||
const promptMock = mock(() => Promise.resolve())
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling promptWithModelSuggestionRetry
|
||||
// when calling promptWithModelSuggestionRetry
|
||||
await promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
@@ -225,12 +225,12 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
},
|
||||
})
|
||||
|
||||
//#then should call prompt exactly once
|
||||
// then should call prompt exactly once
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should retry with suggestion on model-not-found error", async () => {
|
||||
//#given a client that fails first with model-not-found, then succeeds
|
||||
// given a client that fails first with model-not-found, then succeeds
|
||||
const promptMock = mock()
|
||||
.mockRejectedValueOnce({
|
||||
name: "ProviderModelNotFoundError",
|
||||
@@ -243,7 +243,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling promptWithModelSuggestionRetry
|
||||
// when calling promptWithModelSuggestionRetry
|
||||
await promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
@@ -253,7 +253,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
},
|
||||
})
|
||||
|
||||
//#then should call prompt twice - first with original, then with suggestion
|
||||
// then should call prompt twice - first with original, then with suggestion
|
||||
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||
const retryCall = promptMock.mock.calls[1][0]
|
||||
expect(retryCall.body.model).toEqual({
|
||||
@@ -263,13 +263,13 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
})
|
||||
|
||||
it("should throw original error when no suggestion available", async () => {
|
||||
//#given a client that fails with a non-model-not-found error
|
||||
// given a client that fails with a non-model-not-found error
|
||||
const originalError = new Error("Connection refused")
|
||||
const promptMock = mock().mockRejectedValueOnce(originalError)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling promptWithModelSuggestionRetry
|
||||
//#then should throw the original error
|
||||
// when calling promptWithModelSuggestionRetry
|
||||
// then should throw the original error
|
||||
await expect(
|
||||
promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
@@ -284,7 +284,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
})
|
||||
|
||||
it("should throw original error when retry also fails", async () => {
|
||||
//#given a client that fails with model-not-found, retry also fails
|
||||
// given a client that fails with model-not-found, retry also fails
|
||||
const modelNotFoundError = {
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
@@ -299,8 +299,8 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
.mockRejectedValueOnce(retryError)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling promptWithModelSuggestionRetry
|
||||
//#then should throw the retry error (not the original)
|
||||
// when calling promptWithModelSuggestionRetry
|
||||
// then should throw the retry error (not the original)
|
||||
await expect(
|
||||
promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
@@ -315,7 +315,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
})
|
||||
|
||||
it("should preserve other body fields during retry", async () => {
|
||||
//#given a client that fails first with model-not-found
|
||||
// given a client that fails first with model-not-found
|
||||
const promptMock = mock()
|
||||
.mockRejectedValueOnce({
|
||||
name: "ProviderModelNotFoundError",
|
||||
@@ -328,7 +328,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling with additional body fields
|
||||
// when calling with additional body fields
|
||||
await promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
@@ -341,7 +341,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
},
|
||||
})
|
||||
|
||||
//#then retry call should preserve all fields except corrected model
|
||||
// then retry call should preserve all fields except corrected model
|
||||
const retryCall = promptMock.mock.calls[1][0]
|
||||
expect(retryCall.body.agent).toBe("explore")
|
||||
expect(retryCall.body.system).toBe("You are a helpful agent")
|
||||
@@ -354,7 +354,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
})
|
||||
|
||||
it("should handle string error message with suggestion", async () => {
|
||||
//#given a client that fails with a string error containing suggestion
|
||||
// given a client that fails with a string error containing suggestion
|
||||
const promptMock = mock()
|
||||
.mockRejectedValueOnce(
|
||||
new Error("Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4?")
|
||||
@@ -362,7 +362,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling promptWithModelSuggestionRetry
|
||||
// when calling promptWithModelSuggestionRetry
|
||||
await promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
@@ -371,22 +371,22 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
},
|
||||
})
|
||||
|
||||
//#then should retry with suggested model
|
||||
// then should retry with suggested model
|
||||
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||
const retryCall = promptMock.mock.calls[1][0]
|
||||
expect(retryCall.body.model.modelID).toBe("claude-sonnet-4")
|
||||
})
|
||||
|
||||
it("should not retry when no model in original request", async () => {
|
||||
//#given a client that fails with model-not-found but original has no model param
|
||||
// given a client that fails with model-not-found but original has no model param
|
||||
const modelNotFoundError = new Error(
|
||||
"Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4?"
|
||||
)
|
||||
const promptMock = mock().mockRejectedValueOnce(modelNotFoundError)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
//#when calling without model in body
|
||||
//#then should throw without retrying
|
||||
// when calling without model in body
|
||||
// then should throw without retrying
|
||||
await expect(
|
||||
promptWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
|
||||
@@ -37,78 +37,78 @@ describe("opencode-config-dir", () => {
|
||||
|
||||
describe("OPENCODE_CONFIG_DIR environment variable", () => {
|
||||
test("returns OPENCODE_CONFIG_DIR when env var is set", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to a custom path
|
||||
// given OPENCODE_CONFIG_DIR is set to a custom path
|
||||
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns the custom path
|
||||
// then returns the custom path
|
||||
expect(result).toBe("/custom/opencode/path")
|
||||
})
|
||||
|
||||
test("falls back to default when env var is not set", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is not set, platform is Linux
|
||||
// given OPENCODE_CONFIG_DIR is not set, platform is Linux
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns default ~/.config/opencode
|
||||
// then returns default ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("falls back to default when env var is empty string", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to empty string
|
||||
// given OPENCODE_CONFIG_DIR is set to empty string
|
||||
process.env.OPENCODE_CONFIG_DIR = ""
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns default ~/.config/opencode
|
||||
// then returns default ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("falls back to default when env var is whitespace only", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to whitespace only
|
||||
// given OPENCODE_CONFIG_DIR is set to whitespace only
|
||||
process.env.OPENCODE_CONFIG_DIR = " "
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns default ~/.config/opencode
|
||||
// then returns default ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("resolves relative path to absolute path", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to a relative path
|
||||
// given OPENCODE_CONFIG_DIR is set to a relative path
|
||||
process.env.OPENCODE_CONFIG_DIR = "./my-opencode-config"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns resolved absolute path
|
||||
// then returns resolved absolute path
|
||||
expect(result).toBe(resolve("./my-opencode-config"))
|
||||
})
|
||||
|
||||
test("OPENCODE_CONFIG_DIR takes priority over XDG_CONFIG_HOME", () => {
|
||||
// #given both OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME are set
|
||||
// given both OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME are set
|
||||
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path"
|
||||
process.env.XDG_CONFIG_HOME = "/xdg/config"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then OPENCODE_CONFIG_DIR takes priority
|
||||
// then OPENCODE_CONFIG_DIR takes priority
|
||||
expect(result).toBe("/custom/opencode/path")
|
||||
})
|
||||
})
|
||||
@@ -141,116 +141,116 @@ describe("opencode-config-dir", () => {
|
||||
describe("getOpenCodeConfigDir", () => {
|
||||
describe("for opencode CLI binary", () => {
|
||||
test("returns ~/.config/opencode on Linux", () => {
|
||||
// #given opencode CLI binary detected, platform is Linux
|
||||
// given opencode CLI binary detected, platform is Linux
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns ~/.config/opencode
|
||||
// then returns ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("returns $XDG_CONFIG_HOME/opencode on Linux when XDG_CONFIG_HOME is set", () => {
|
||||
// #given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set
|
||||
// given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
process.env.XDG_CONFIG_HOME = "/custom/config"
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns $XDG_CONFIG_HOME/opencode
|
||||
// then returns $XDG_CONFIG_HOME/opencode
|
||||
expect(result).toBe("/custom/config/opencode")
|
||||
})
|
||||
|
||||
test("returns ~/.config/opencode on macOS", () => {
|
||||
// #given opencode CLI binary detected, platform is macOS
|
||||
// given opencode CLI binary detected, platform is macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns ~/.config/opencode
|
||||
// then returns ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("returns ~/.config/opencode on Windows by default", () => {
|
||||
// #given opencode CLI binary detected, platform is Windows
|
||||
// given opencode CLI binary detected, platform is Windows
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
delete process.env.APPDATA
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns ~/.config/opencode (cross-platform default)
|
||||
// then returns ~/.config/opencode (cross-platform default)
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("for opencode-desktop Tauri binary", () => {
|
||||
test("returns ~/.config/ai.opencode.desktop on Linux", () => {
|
||||
// #given opencode-desktop binary detected, platform is Linux
|
||||
// given opencode-desktop binary detected, platform is Linux
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns ~/.config/ai.opencode.desktop
|
||||
// then returns ~/.config/ai.opencode.desktop
|
||||
expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER))
|
||||
})
|
||||
|
||||
test("returns ~/Library/Application Support/ai.opencode.desktop on macOS", () => {
|
||||
// #given opencode-desktop binary detected, platform is macOS
|
||||
// given opencode-desktop binary detected, platform is macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns ~/Library/Application Support/ai.opencode.desktop
|
||||
// then returns ~/Library/Application Support/ai.opencode.desktop
|
||||
expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER))
|
||||
})
|
||||
|
||||
test("returns %APPDATA%/ai.opencode.desktop on Windows", () => {
|
||||
// #given opencode-desktop binary detected, platform is Windows
|
||||
// given opencode-desktop binary detected, platform is Windows
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
process.env.APPDATA = "C:\\Users\\TestUser\\AppData\\Roaming"
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
// when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns %APPDATA%/ai.opencode.desktop
|
||||
// then returns %APPDATA%/ai.opencode.desktop
|
||||
expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Roaming", TAURI_APP_IDENTIFIER))
|
||||
})
|
||||
})
|
||||
|
||||
describe("dev build detection", () => {
|
||||
test("returns ai.opencode.desktop.dev path when dev version detected", () => {
|
||||
// #given opencode-desktop dev version
|
||||
// given opencode-desktop dev version
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigDir is called with dev version
|
||||
// when getOpenCodeConfigDir is called with dev version
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev.123", checkExisting: false })
|
||||
|
||||
// #then returns path with ai.opencode.desktop.dev
|
||||
// then returns path with ai.opencode.desktop.dev
|
||||
expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER_DEV))
|
||||
})
|
||||
|
||||
test("returns ai.opencode.desktop.dev on macOS for dev build", () => {
|
||||
// #given opencode-desktop dev version on macOS
|
||||
// given opencode-desktop dev version on macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with dev version
|
||||
// when getOpenCodeConfigDir is called with dev version
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev", checkExisting: false })
|
||||
|
||||
// #then returns path with ai.opencode.desktop.dev
|
||||
// then returns path with ai.opencode.desktop.dev
|
||||
expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER_DEV))
|
||||
})
|
||||
})
|
||||
@@ -258,15 +258,15 @@ describe("opencode-config-dir", () => {
|
||||
|
||||
describe("getOpenCodeConfigPaths", () => {
|
||||
test("returns all config paths for CLI binary", () => {
|
||||
// #given opencode CLI binary on Linux
|
||||
// given opencode CLI binary on Linux
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when getOpenCodeConfigPaths is called
|
||||
// when getOpenCodeConfigPaths is called
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns all expected paths
|
||||
// then returns all expected paths
|
||||
const expectedDir = join(homedir(), ".config", "opencode")
|
||||
expect(paths.configDir).toBe(expectedDir)
|
||||
expect(paths.configJson).toBe(join(expectedDir, "opencode.json"))
|
||||
@@ -276,13 +276,13 @@ describe("opencode-config-dir", () => {
|
||||
})
|
||||
|
||||
test("returns all config paths for desktop binary", () => {
|
||||
// #given opencode-desktop binary on macOS
|
||||
// given opencode-desktop binary on macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
// #when getOpenCodeConfigPaths is called
|
||||
// when getOpenCodeConfigPaths is called
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns all expected paths
|
||||
// then returns all expected paths
|
||||
const expectedDir = join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER)
|
||||
expect(paths.configDir).toBe(expectedDir)
|
||||
expect(paths.configJson).toBe(join(expectedDir, "opencode.json"))
|
||||
@@ -294,28 +294,28 @@ describe("opencode-config-dir", () => {
|
||||
|
||||
describe("detectExistingConfigDir", () => {
|
||||
test("returns null when no config exists", () => {
|
||||
// #given no config files exist
|
||||
// given no config files exist
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when detectExistingConfigDir is called
|
||||
// when detectExistingConfigDir is called
|
||||
const result = detectExistingConfigDir("opencode", "1.0.200")
|
||||
|
||||
// #then result is either null or a valid string path
|
||||
// then result is either null or a valid string path
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
|
||||
test("includes OPENCODE_CONFIG_DIR in search locations when set", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to a custom path
|
||||
// given OPENCODE_CONFIG_DIR is set to a custom path
|
||||
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when detectExistingConfigDir is called
|
||||
// when detectExistingConfigDir is called
|
||||
const result = detectExistingConfigDir("opencode", "1.0.200")
|
||||
|
||||
// #then result is either null (no config file exists) or a valid string path
|
||||
// then result is either null (no config file exists) or a valid string path
|
||||
// The important thing is that the function doesn't throw
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
|
||||
@@ -13,89 +13,89 @@ import {
|
||||
describe("opencode-version", () => {
|
||||
describe("parseVersion", () => {
|
||||
test("parses simple version", () => {
|
||||
// #given a simple version string
|
||||
// given a simple version string
|
||||
const version = "1.2.3"
|
||||
|
||||
// #when parsed
|
||||
// when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then returns array of numbers
|
||||
// then returns array of numbers
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles v prefix", () => {
|
||||
// #given version with v prefix
|
||||
// given version with v prefix
|
||||
const version = "v1.2.3"
|
||||
|
||||
// #when parsed
|
||||
// when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then strips prefix and parses correctly
|
||||
// then strips prefix and parses correctly
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles prerelease suffix", () => {
|
||||
// #given version with prerelease
|
||||
// given version with prerelease
|
||||
const version = "1.2.3-beta.1"
|
||||
|
||||
// #when parsed
|
||||
// when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then ignores prerelease part
|
||||
// then ignores prerelease part
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles two-part version", () => {
|
||||
// #given two-part version
|
||||
// given two-part version
|
||||
const version = "1.2"
|
||||
|
||||
// #when parsed
|
||||
// when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then returns two numbers
|
||||
// then returns two numbers
|
||||
expect(result).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("compareVersions", () => {
|
||||
test("returns 0 for equal versions", () => {
|
||||
// #given two equal versions
|
||||
// #when compared
|
||||
// given two equal versions
|
||||
// when compared
|
||||
const result = compareVersions("1.1.1", "1.1.1")
|
||||
|
||||
// #then returns 0
|
||||
// then returns 0
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
test("returns 1 when a > b", () => {
|
||||
// #given a is greater than b
|
||||
// #when compared
|
||||
// given a is greater than b
|
||||
// when compared
|
||||
const result = compareVersions("1.2.0", "1.1.0")
|
||||
|
||||
// #then returns 1
|
||||
// then returns 1
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
test("returns -1 when a < b", () => {
|
||||
// #given a is less than b
|
||||
// #when compared
|
||||
// given a is less than b
|
||||
// when compared
|
||||
const result = compareVersions("1.0.9", "1.1.0")
|
||||
|
||||
// #then returns -1
|
||||
// then returns -1
|
||||
expect(result).toBe(-1)
|
||||
})
|
||||
|
||||
test("handles different length versions", () => {
|
||||
// #given versions with different lengths
|
||||
// #when compared
|
||||
// given versions with different lengths
|
||||
// when compared
|
||||
expect(compareVersions("1.1", "1.1.0")).toBe(0)
|
||||
expect(compareVersions("1.1.1", "1.1")).toBe(1)
|
||||
expect(compareVersions("1.1", "1.1.1")).toBe(-1)
|
||||
})
|
||||
|
||||
test("handles major version differences", () => {
|
||||
// #given major version difference
|
||||
// #when compared
|
||||
// given major version difference
|
||||
// when compared
|
||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1)
|
||||
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1)
|
||||
})
|
||||
@@ -112,24 +112,24 @@ describe("opencode-version", () => {
|
||||
})
|
||||
|
||||
test("returns cached version on subsequent calls", () => {
|
||||
// #given version is set in cache
|
||||
// given version is set in cache
|
||||
setVersionCache("1.2.3")
|
||||
|
||||
// #when getting version
|
||||
// when getting version
|
||||
const result = getOpenCodeVersion()
|
||||
|
||||
// #then returns cached value
|
||||
// then returns cached value
|
||||
expect(result).toBe("1.2.3")
|
||||
})
|
||||
|
||||
test("returns null when cache is set to null", () => {
|
||||
// #given cache is explicitly set to null
|
||||
// given cache is explicitly set to null
|
||||
setVersionCache(null)
|
||||
|
||||
// #when getting version (cache is already set)
|
||||
// when getting version (cache is already set)
|
||||
const result = getOpenCodeVersion()
|
||||
|
||||
// #then returns null without executing command
|
||||
// then returns null without executing command
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -144,46 +144,46 @@ describe("opencode-version", () => {
|
||||
})
|
||||
|
||||
test("returns true for exact version", () => {
|
||||
// #given version is 1.1.1
|
||||
// given version is 1.1.1
|
||||
setVersionCache("1.1.1")
|
||||
|
||||
// #when checking against 1.1.1
|
||||
// when checking against 1.1.1
|
||||
const result = isOpenCodeVersionAtLeast("1.1.1")
|
||||
|
||||
// #then returns true
|
||||
// then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for versions above target", () => {
|
||||
// #given version is above target
|
||||
// given version is above target
|
||||
setVersionCache("1.2.0")
|
||||
|
||||
// #when checking against 1.1.1
|
||||
// when checking against 1.1.1
|
||||
const result = isOpenCodeVersionAtLeast("1.1.1")
|
||||
|
||||
// #then returns true
|
||||
// then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for versions below target", () => {
|
||||
// #given version is below target
|
||||
// given version is below target
|
||||
setVersionCache("1.1.0")
|
||||
|
||||
// #when checking against 1.1.1
|
||||
// when checking against 1.1.1
|
||||
const result = isOpenCodeVersionAtLeast("1.1.1")
|
||||
|
||||
// #then returns false
|
||||
// then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when version cannot be detected", () => {
|
||||
// #given version is null (undetectable)
|
||||
// given version is null (undetectable)
|
||||
setVersionCache(null)
|
||||
|
||||
// #when checking
|
||||
// when checking
|
||||
const result = isOpenCodeVersionAtLeast("1.1.1")
|
||||
|
||||
// #then returns true (assume newer version)
|
||||
// then returns true (assume newer version)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -196,42 +196,42 @@ describe("opencode-version", () => {
|
||||
|
||||
describe("OPENCODE_NATIVE_AGENTS_INJECTION_VERSION", () => {
|
||||
test("is set to 1.1.37", () => {
|
||||
// #given the native agents injection version constant
|
||||
// #when exported
|
||||
// #then it should be 1.1.37 (PR #10678)
|
||||
// given the native agents injection version constant
|
||||
// when exported
|
||||
// then it should be 1.1.37 (PR #10678)
|
||||
expect(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION).toBe("1.1.37")
|
||||
})
|
||||
|
||||
test("version detection works correctly with native agents version", () => {
|
||||
// #given OpenCode version at or above native agents injection version
|
||||
// given OpenCode version at or above native agents injection version
|
||||
setVersionCache("1.1.37")
|
||||
|
||||
// #when checking against native agents version
|
||||
// when checking against native agents version
|
||||
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
|
||||
|
||||
// #then returns true (native support available)
|
||||
// then returns true (native support available)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("version detection returns false for older versions", () => {
|
||||
// #given OpenCode version below native agents injection version
|
||||
// given OpenCode version below native agents injection version
|
||||
setVersionCache("1.1.36")
|
||||
|
||||
// #when checking against native agents version
|
||||
// when checking against native agents version
|
||||
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
|
||||
|
||||
// #then returns false (no native support)
|
||||
// then returns false (no native support)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when version detection fails (fail-safe)", () => {
|
||||
// #given version cannot be detected
|
||||
// given version cannot be detected
|
||||
setVersionCache(null)
|
||||
|
||||
// #when checking against native agents version
|
||||
// when checking against native agents version
|
||||
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
|
||||
|
||||
// #then returns true (assume latest, enable native support)
|
||||
// then returns true (assume latest, enable native support)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,44 +9,44 @@ import {
|
||||
describe("permission-compat", () => {
|
||||
describe("createAgentToolRestrictions", () => {
|
||||
test("returns permission format with deny values", () => {
|
||||
// #given tools to restrict
|
||||
// #when creating restrictions
|
||||
// given tools to restrict
|
||||
// when creating restrictions
|
||||
const result = createAgentToolRestrictions(["write", "edit"])
|
||||
|
||||
// #then returns permission format
|
||||
// then returns permission format
|
||||
expect(result).toEqual({
|
||||
permission: { write: "deny", edit: "deny" },
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty permission for empty array", () => {
|
||||
// #given empty tools array
|
||||
// #when creating restrictions
|
||||
// given empty tools array
|
||||
// when creating restrictions
|
||||
const result = createAgentToolRestrictions([])
|
||||
|
||||
// #then returns empty permission
|
||||
// then returns empty permission
|
||||
expect(result).toEqual({ permission: {} })
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAgentToolAllowlist", () => {
|
||||
test("returns wildcard deny with explicit allow", () => {
|
||||
// #given tools to allow
|
||||
// #when creating allowlist
|
||||
// given tools to allow
|
||||
// when creating allowlist
|
||||
const result = createAgentToolAllowlist(["read"])
|
||||
|
||||
// #then returns wildcard deny with read allow
|
||||
// then returns wildcard deny with read allow
|
||||
expect(result).toEqual({
|
||||
permission: { "*": "deny", read: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("returns wildcard deny with multiple allows", () => {
|
||||
// #given multiple tools to allow
|
||||
// #when creating allowlist
|
||||
// given multiple tools to allow
|
||||
// when creating allowlist
|
||||
const result = createAgentToolAllowlist(["read", "glob"])
|
||||
|
||||
// #then returns wildcard deny with both allows
|
||||
// then returns wildcard deny with both allows
|
||||
expect(result).toEqual({
|
||||
permission: { "*": "deny", read: "allow", glob: "allow" },
|
||||
})
|
||||
@@ -55,13 +55,13 @@ describe("permission-compat", () => {
|
||||
|
||||
describe("migrateToolsToPermission", () => {
|
||||
test("converts boolean tools to permission values", () => {
|
||||
// #given tools config
|
||||
// given tools config
|
||||
const tools = { write: false, edit: true, bash: false }
|
||||
|
||||
// #when migrating
|
||||
// when migrating
|
||||
const result = migrateToolsToPermission(tools)
|
||||
|
||||
// #then converts correctly
|
||||
// then converts correctly
|
||||
expect(result).toEqual({
|
||||
write: "deny",
|
||||
edit: "allow",
|
||||
@@ -72,23 +72,23 @@ describe("permission-compat", () => {
|
||||
|
||||
describe("migrateAgentConfig", () => {
|
||||
test("migrates tools to permission", () => {
|
||||
// #given config with tools
|
||||
// given config with tools
|
||||
const config = {
|
||||
model: "test",
|
||||
tools: { write: false, edit: false },
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
// when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then converts to permission
|
||||
// then converts to permission
|
||||
expect(result.tools).toBeUndefined()
|
||||
expect(result.permission).toEqual({ write: "deny", edit: "deny" })
|
||||
expect(result.model).toBe("test")
|
||||
})
|
||||
|
||||
test("preserves other config fields", () => {
|
||||
// #given config with other fields
|
||||
// given config with other fields
|
||||
const config = {
|
||||
model: "test",
|
||||
temperature: 0.5,
|
||||
@@ -96,38 +96,38 @@ describe("permission-compat", () => {
|
||||
tools: { write: false },
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
// when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then preserves other fields
|
||||
// then preserves other fields
|
||||
expect(result.model).toBe("test")
|
||||
expect(result.temperature).toBe(0.5)
|
||||
expect(result.prompt).toBe("hello")
|
||||
})
|
||||
|
||||
test("merges existing permission with migrated tools", () => {
|
||||
// #given config with both tools and permission
|
||||
// given config with both tools and permission
|
||||
const config = {
|
||||
tools: { write: false },
|
||||
permission: { bash: "deny" as const },
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
// when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then merges permission (existing takes precedence)
|
||||
// then merges permission (existing takes precedence)
|
||||
expect(result.tools).toBeUndefined()
|
||||
expect(result.permission).toEqual({ write: "deny", bash: "deny" })
|
||||
})
|
||||
|
||||
test("returns unchanged config if no tools", () => {
|
||||
// #given config without tools
|
||||
// given config without tools
|
||||
const config = { model: "test", permission: { edit: "deny" as const } }
|
||||
|
||||
// #when migrating
|
||||
// when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then returns unchanged
|
||||
// then returns unchanged
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,54 +13,54 @@ describe("consumeNewMessages", () => {
|
||||
})
|
||||
|
||||
it("returns all messages on first read and none on repeat", () => {
|
||||
// #given
|
||||
// given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const first = consumeNewMessages(sessionID, messages)
|
||||
const second = consumeNewMessages(sessionID, messages)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(first).toEqual(messages)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it("returns only new messages after cursor advances", () => {
|
||||
// #given
|
||||
// given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const extended = [...messages, buildMessage("m3", 3)]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const next = consumeNewMessages(sessionID, extended)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(next).toEqual([extended[2]])
|
||||
})
|
||||
|
||||
it("resets when message history shrinks", () => {
|
||||
// #given
|
||||
// given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const shorter = [buildMessage("n1", 1)]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const next = consumeNewMessages(sessionID, shorter)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(next).toEqual(shorter)
|
||||
})
|
||||
|
||||
it("returns all messages when last key is missing", () => {
|
||||
// #given
|
||||
// given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const replaced = [buildMessage("n1", 1), buildMessage("n2", 2)]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const next = consumeNewMessages(sessionID, replaced)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(next).toEqual(replaced)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
|
||||
describe("isInsideTmux", () => {
|
||||
test("returns true when TMUX env is set", () => {
|
||||
// #given
|
||||
// given
|
||||
const originalTmux = process.env.TMUX
|
||||
process.env.TMUX = "/tmp/tmux-1000/default"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isInsideTmux()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
|
||||
// cleanup
|
||||
@@ -25,14 +25,14 @@ describe("isInsideTmux", () => {
|
||||
})
|
||||
|
||||
test("returns false when TMUX env is not set", () => {
|
||||
// #given
|
||||
// given
|
||||
const originalTmux = process.env.TMUX
|
||||
delete process.env.TMUX
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isInsideTmux()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
|
||||
// cleanup
|
||||
@@ -40,14 +40,14 @@ describe("isInsideTmux", () => {
|
||||
})
|
||||
|
||||
test("returns false when TMUX env is empty string", () => {
|
||||
// #given
|
||||
// given
|
||||
const originalTmux = process.env.TMUX
|
||||
process.env.TMUX = ""
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isInsideTmux()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
|
||||
// cleanup
|
||||
@@ -67,100 +67,100 @@ describe("isServerRunning", () => {
|
||||
})
|
||||
|
||||
test("returns true when server responds OK", async () => {
|
||||
// #given
|
||||
// given
|
||||
globalThis.fetch = mock(async () => ({ ok: true })) as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await isServerRunning("http://localhost:4096")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when server not reachable", async () => {
|
||||
// #given
|
||||
// given
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new Error("ECONNREFUSED")
|
||||
}) as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await isServerRunning("http://localhost:4096")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when fetch returns not ok", async () => {
|
||||
// #given
|
||||
// given
|
||||
globalThis.fetch = mock(async () => ({ ok: false })) as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await isServerRunning("http://localhost:4096")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("caches successful result", async () => {
|
||||
// #given
|
||||
// given
|
||||
const fetchMock = mock(async () => ({ ok: true })) as any
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
// #when
|
||||
// when
|
||||
await isServerRunning("http://localhost:4096")
|
||||
await isServerRunning("http://localhost:4096")
|
||||
|
||||
// #then - should only call fetch once due to caching
|
||||
// then - should only call fetch once due to caching
|
||||
expect(fetchMock.mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("does not cache failed result", async () => {
|
||||
// #given
|
||||
// given
|
||||
const fetchMock = mock(async () => {
|
||||
throw new Error("ECONNREFUSED")
|
||||
}) as any
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
// #when
|
||||
// when
|
||||
await isServerRunning("http://localhost:4096")
|
||||
await isServerRunning("http://localhost:4096")
|
||||
|
||||
// #then - should call fetch 4 times (2 attempts per call, 2 calls)
|
||||
// then - should call fetch 4 times (2 attempts per call, 2 calls)
|
||||
expect(fetchMock.mock.calls.length).toBe(4)
|
||||
})
|
||||
|
||||
test("uses different cache for different URLs", async () => {
|
||||
// #given
|
||||
// given
|
||||
const fetchMock = mock(async () => ({ ok: true })) as any
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
// #when
|
||||
// when
|
||||
await isServerRunning("http://localhost:4096")
|
||||
await isServerRunning("http://localhost:5000")
|
||||
|
||||
// #then - should call fetch twice for different URLs
|
||||
// then - should call fetch twice for different URLs
|
||||
expect(fetchMock.mock.calls.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetServerCheck", () => {
|
||||
test("clears cache without throwing", () => {
|
||||
// #given, #when, #then
|
||||
// given, #when, #then
|
||||
expect(() => resetServerCheck()).not.toThrow()
|
||||
})
|
||||
|
||||
test("allows re-checking after reset", async () => {
|
||||
// #given
|
||||
// given
|
||||
const originalFetch = globalThis.fetch
|
||||
const fetchMock = mock(async () => ({ ok: true })) as any
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
// #when
|
||||
// when
|
||||
await isServerRunning("http://localhost:4096")
|
||||
resetServerCheck()
|
||||
await isServerRunning("http://localhost:4096")
|
||||
|
||||
// #then - should call fetch twice after reset
|
||||
// then - should call fetch twice after reset
|
||||
expect(fetchMock.mock.calls.length).toBe(2)
|
||||
|
||||
// cleanup
|
||||
@@ -170,26 +170,26 @@ describe("resetServerCheck", () => {
|
||||
|
||||
describe("tmux pane functions", () => {
|
||||
test("spawnTmuxPane is exported as function", async () => {
|
||||
// #given, #when
|
||||
// given, #when
|
||||
const result = typeof spawnTmuxPane
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("function")
|
||||
})
|
||||
|
||||
test("closeTmuxPane is exported as function", async () => {
|
||||
// #given, #when
|
||||
// given, #when
|
||||
const result = typeof closeTmuxPane
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("function")
|
||||
})
|
||||
|
||||
test("applyLayout is exported as function", async () => {
|
||||
// #given, #when
|
||||
// given, #when
|
||||
const result = typeof applyLayout
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user