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:
YeonGyu-Kim
2026-02-01 16:47:50 +09:00
committed by GitHub
parent c83150d9ea
commit f146aeff0f
145 changed files with 10307 additions and 9562 deletions
+44 -44
View File
@@ -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)")
})
+39 -39
View File
@@ -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)
})
})
+42 -42
View File
@@ -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")
})
})
+78 -78
View File
@@ -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++) {
+47 -47
View File
@@ -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")
+8 -8
View File
@@ -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)
})
})
+36 -36
View File
@@ -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")
+54 -54
View File
@@ -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
View File
@@ -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)
+102 -102
View File
@@ -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)
})
})
+79 -79
View File
@@ -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
View File
@@ -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")
+60 -60
View File
@@ -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" },
+57 -57
View File
@@ -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)
})
+55 -55
View File
@@ -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)
})
})
+27 -27
View File
@@ -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)
})
})
+12 -12
View File
@@ -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)
})
})
+37 -37
View File
@@ -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")
})
})