test(ci): remove suite-order mock coupling

This commit is contained in:
YeonGyu-Kim
2026-05-15 18:21:04 +09:00
parent f1fb1e08eb
commit a02686e729
17 changed files with 387 additions and 401 deletions
@@ -1,11 +1,13 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler"
import type { FallbackEntry } from "../../shared/model-requirements"
const sharedLogMock = mock(() => {}) const sharedLogMock = mock(() => {})
const readConnectedProvidersCacheMock = mock(() => null) const readConnectedProvidersCacheMock = mock(() => null)
const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null) const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null)
const shouldRetryErrorMock = mock(() => true) const shouldRetryErrorMock = mock(() => true)
const getNextFallbackMock = mock((chain: Array<{ model: string }>, attempt: number) => chain[attempt]) const getNextFallbackMock = mock((chain: FallbackEntry[], attempt: number) => chain[attempt])
const hasMoreFallbacksMock = mock((chain: Array<{ model: string }>, attempt: number) => attempt < chain.length) const hasMoreFallbacksMock = mock((chain: FallbackEntry[], attempt: number) => attempt < chain.length)
const selectFallbackProviderMock = mock((providers: string[]) => providers[0]) const selectFallbackProviderMock = mock((providers: string[]) => providers[0])
const transformModelForProviderMock = mock((_provider: string, model: string) => model) const transformModelForProviderMock = mock((_provider: string, model: string) => model)
@@ -13,41 +15,17 @@ import type { BackgroundTask } from "./types"
import type { ConcurrencyManager } from "./concurrency" import type { ConcurrencyManager } from "./concurrency"
import type { OpencodeClient, QueueItem } from "./constants" import type { OpencodeClient, QueueItem } from "./constants"
async function importFreshFallbackRetryHandlerModule() { const retryHandlerDeps: Partial<FallbackRetryHandlerDeps> = {
mock.module("../../shared/logger", () => ({ log: sharedLogMock,
log: sharedLogMock, readConnectedProvidersCache: readConnectedProvidersCacheMock,
})) readProviderModelsCache: readProviderModelsCacheMock,
shouldRetryError: shouldRetryErrorMock,
mock.module("../../shared/connected-providers-cache", () => ({ getNextFallback: getNextFallbackMock,
readConnectedProvidersCache: readConnectedProvidersCacheMock, hasMoreFallbacks: hasMoreFallbacksMock,
readProviderModelsCache: readProviderModelsCacheMock, selectFallbackProvider: selectFallbackProviderMock,
})) transformModelForProvider: transformModelForProviderMock,
mock.module("../../shared/model-error-classifier", () => ({
shouldRetryError: shouldRetryErrorMock,
getNextFallback: getNextFallbackMock,
hasMoreFallbacks: hasMoreFallbacksMock,
selectFallbackProvider: selectFallbackProviderMock,
}))
mock.module("../../shared/provider-model-id-transform", () => ({
transformModelForProvider: transformModelForProviderMock,
}))
const retryHandlerModule = await import(`./fallback-retry-handler?test=${Date.now()}-${Math.random()}`)
mock.restore()
return {
tryFallbackRetry: retryHandlerModule.tryFallbackRetry,
shouldRetryError: shouldRetryErrorMock,
selectFallbackProvider: selectFallbackProviderMock,
readProviderModelsCache: readProviderModelsCacheMock,
}
} }
const { tryFallbackRetry, shouldRetryError, selectFallbackProvider, readProviderModelsCache } =
await importFreshFallbackRetryHandlerModule()
function createDeferredPromise(): { function createDeferredPromise(): {
promise: Promise<void> promise: Promise<void>
resolve: () => void resolve: () => void
@@ -124,6 +102,7 @@ function createDefaultArgs(taskOverrides: Partial<BackgroundTask> = {}) {
idleDeferralTimers, idleDeferralTimers,
queuesByKey, queuesByKey,
processKey: processKeyFn, processKey: processKeyFn,
deps: retryHandlerDeps,
} }
} }
@@ -133,9 +112,13 @@ describe("tryFallbackRetry", () => {
}) })
beforeEach(() => { beforeEach(() => {
shouldRetryError.mockImplementation(() => true) shouldRetryErrorMock.mockImplementation(() => true)
selectFallbackProvider.mockImplementation((providers: string[]) => providers[0]) selectFallbackProviderMock.mockImplementation((providers: string[]) => providers[0])
readProviderModelsCache.mockReturnValue(null) readProviderModelsCacheMock.mockReturnValue(null)
readConnectedProvidersCacheMock.mockReturnValue(null)
getNextFallbackMock.mockImplementation((chain: FallbackEntry[], attempt: number) => chain[attempt])
hasMoreFallbacksMock.mockImplementation((chain: FallbackEntry[], attempt: number) => attempt < chain.length)
transformModelForProviderMock.mockImplementation((_provider: string, model: string) => model)
}) })
describe("#given retryable error with fallback chain", () => { describe("#given retryable error with fallback chain", () => {
@@ -332,7 +315,7 @@ describe("tryFallbackRetry", () => {
describe("#given non-retryable error", () => { describe("#given non-retryable error", () => {
test("returns false when shouldRetryError returns false", async () => { test("returns false when shouldRetryError returns false", async () => {
shouldRetryError.mockImplementation(() => false) shouldRetryErrorMock.mockImplementation(() => false)
const args = createDefaultArgs() const args = createDefaultArgs()
const result = await tryFallbackRetry(args) const result = await tryFallbackRetry(args)
@@ -433,8 +416,8 @@ describe("tryFallbackRetry", () => {
describe("#given disconnected fallback providers with connected preferred provider", () => { describe("#given disconnected fallback providers with connected preferred provider", () => {
test("keeps fallback entry and selects connected preferred provider", async () => { test("keeps fallback entry and selects connected preferred provider", async () => {
readProviderModelsCache.mockReturnValueOnce({ connected: ["provider-a"] }) readProviderModelsCacheMock.mockReturnValueOnce({ connected: ["provider-a"] })
selectFallbackProvider.mockImplementationOnce( selectFallbackProviderMock.mockImplementationOnce(
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b", (_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
) )
@@ -17,6 +17,28 @@ function canonicalizeModelID(modelID: string): string {
return modelID.toLowerCase().replace(/\./g, "-") return modelID.toLowerCase().replace(/\./g, "-")
} }
export type FallbackRetryHandlerDeps = {
log: typeof log
readProviderModelsCache: typeof readProviderModelsCache
readConnectedProvidersCache: typeof readConnectedProvidersCache
shouldRetryError: typeof shouldRetryError
getNextFallback: typeof getNextFallback
hasMoreFallbacks: typeof hasMoreFallbacks
selectFallbackProvider: typeof selectFallbackProvider
transformModelForProvider: typeof transformModelForProvider
}
const defaultFallbackRetryHandlerDeps: FallbackRetryHandlerDeps = {
log,
readProviderModelsCache,
readConnectedProvidersCache,
shouldRetryError,
getNextFallback,
hasMoreFallbacks,
selectFallbackProvider,
transformModelForProvider,
}
export async function tryFallbackRetry(args: { export async function tryFallbackRetry(args: {
task: BackgroundTask task: BackgroundTask
errorInfo: { name?: string; message?: string } errorInfo: { name?: string; message?: string }
@@ -34,20 +56,22 @@ export async function tryFallbackRetry(args: {
failedError?: string failedError?: string
nextModel: string nextModel: string
}) => void }) => void
deps?: Partial<FallbackRetryHandlerDeps>
}): Promise<boolean> { }): Promise<boolean> {
const { task, errorInfo, source, concurrencyManager, client, idleDeferralTimers, queuesByKey, processKey, onRetrying } = args const { task, errorInfo, source, concurrencyManager, client, idleDeferralTimers, queuesByKey, processKey, onRetrying } = args
const deps = { ...defaultFallbackRetryHandlerDeps, ...args.deps }
const fallbackChain = task.fallbackChain const fallbackChain = task.fallbackChain
const canRetry = const canRetry =
shouldRetryError(errorInfo) && deps.shouldRetryError(errorInfo) &&
fallbackChain && fallbackChain &&
fallbackChain.length > 0 && fallbackChain.length > 0 &&
hasMoreFallbacks(fallbackChain, task.attemptCount ?? 0) deps.hasMoreFallbacks(fallbackChain, task.attemptCount ?? 0)
if (!canRetry) return false if (!canRetry) return false
const attemptCount = task.attemptCount ?? 0 const attemptCount = task.attemptCount ?? 0
const providerModelsCache = readProviderModelsCache() const providerModelsCache = deps.readProviderModelsCache()
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache() const connectedProviders = providerModelsCache?.connected ?? deps.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders.map(p => p.toLowerCase())) : null const connectedSet = connectedProviders ? new Set(connectedProviders.map(p => p.toLowerCase())) : null
const preferredProvider = task.model?.providerID?.toLowerCase() const preferredProvider = task.model?.providerID?.toLowerCase()
@@ -63,11 +87,11 @@ export async function tryFallbackRetry(args: {
let nextFallback: FallbackEntry | undefined let nextFallback: FallbackEntry | undefined
let nextProviderID: string | undefined let nextProviderID: string | undefined
while (fallbackChain && selectedAttemptCount < fallbackChain.length) { while (fallbackChain && selectedAttemptCount < fallbackChain.length) {
const candidate = getNextFallback(fallbackChain, selectedAttemptCount) const candidate = deps.getNextFallback(fallbackChain, selectedAttemptCount)
if (!candidate) break if (!candidate) break
selectedAttemptCount++ selectedAttemptCount++
if (!isReachable(candidate)) { if (!isReachable(candidate)) {
log("[background-agent] Skipping unreachable fallback:", { deps.log("[background-agent] Skipping unreachable fallback:", {
taskId: task.id, taskId: task.id,
source, source,
model: candidate.model, model: candidate.model,
@@ -75,17 +99,17 @@ export async function tryFallbackRetry(args: {
}) })
continue continue
} }
const candidateProviderID = selectFallbackProvider( const candidateProviderID = deps.selectFallbackProvider(
candidate.providers, candidate.providers,
task.model?.providerID, task.model?.providerID,
) )
const candidateModelID = transformModelForProvider(candidateProviderID, candidate.model) const candidateModelID = deps.transformModelForProvider(candidateProviderID, candidate.model)
const isNoOpFallback = const isNoOpFallback =
!!task.model && !!task.model &&
candidateProviderID.toLowerCase() === task.model.providerID.toLowerCase() && candidateProviderID.toLowerCase() === task.model.providerID.toLowerCase() &&
canonicalizeModelID(candidateModelID) === canonicalizeModelID(task.model.modelID) canonicalizeModelID(candidateModelID) === canonicalizeModelID(task.model.modelID)
if (isNoOpFallback) { if (isNoOpFallback) {
log("[background-agent] Skipping no-op fallback:", { deps.log("[background-agent] Skipping no-op fallback:", {
taskId: task.id, taskId: task.id,
source, source,
model: candidate.model, model: candidate.model,
@@ -99,12 +123,12 @@ export async function tryFallbackRetry(args: {
} }
if (!nextFallback) return false if (!nextFallback) return false
const providerID = nextProviderID ?? selectFallbackProvider( const providerID = nextProviderID ?? deps.selectFallbackProvider(
nextFallback.providers, nextFallback.providers,
task.model?.providerID, task.model?.providerID,
) )
log("[background-agent] Retryable error, attempting fallback:", { deps.log("[background-agent] Retryable error, attempting fallback:", {
taskId: task.id, taskId: task.id,
source, source,
errorName: errorInfo.name, errorName: errorInfo.name,
@@ -127,7 +151,7 @@ export async function tryFallbackRetry(args: {
const previousSessionID = task.sessionId const previousSessionID = task.sessionId
const previousModel = task.model const previousModel = task.model
const transformedModelId = transformModelForProvider(providerID, nextFallback.model) const transformedModelId = deps.transformModelForProvider(providerID, nextFallback.model)
const nextModel = { const nextModel = {
providerID, providerID,
modelID: transformedModelId, modelID: transformedModelId,
@@ -16,16 +16,6 @@ import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager" import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup" import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
mock.module("../../shared/connected-providers-cache", () => ({
readConnectedProvidersCache: () => null,
readProviderModelsCache: () => null,
hasConnectedProvidersCache: () => false,
hasProviderModelsCache: () => false,
writeProviderModelsCache: () => {},
updateConnectedProvidersCache: () => {},
}))
mock.restore()
const TASK_TTL_MS = 30 * 60 * 1000 const TASK_TTL_MS = 30 * 60 * 1000
type PendingParentWakeForTest = { type PendingParentWakeForTest = {
@@ -1,6 +1,6 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test" import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { TeamModeConfigSchema } from "../../../config/schema/team-mode" import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
import type { BackgroundManager } from "../../background-agent/manager" import type { BackgroundManager } from "../../background-agent/manager"
@@ -14,6 +14,10 @@ import {
} from "./session-cleanup" } from "./session-cleanup"
describe("session team cleanup", () => { describe("session team cleanup", () => {
beforeEach(() => {
clearSessionTeamRunCleanupRegistry()
})
afterEach(() => { afterEach(() => {
clearSessionTeamRunCleanupRegistry() clearSessionTeamRunCleanupRegistry()
mock.restore() mock.restore()
@@ -149,7 +149,10 @@ describe("Atlas final-wave approval gate regressions", () => {
- [ ] All tests pass - [ ] All tests pass
`) `)
const hook = createAtlasHook(createMockPluginInput()) const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = { const toolOutput = {
title: "Sisyphus Task", title: "Sisyphus Task",
output: `Tasks [1/1 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE output: `Tasks [1/1 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -186,7 +189,10 @@ session_id: ses_nested_scope_review
- [ ] F4. **Scope Fidelity Check** - \`deep\` - [ ] F4. **Scope Fidelity Check** - \`deep\`
`) `)
const hook = createAtlasHook(createMockPluginInput()) const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const firstThreeOutputs = [1, 2, 3].map((index) => ({ const firstThreeOutputs = [1, 2, 3].map((index) => ({
title: `Final review ${index}`, title: `Final review ${index}`,
output: `Reviewer ${index} | VERDICT: APPROVE output: `Reviewer ${index} | VERDICT: APPROVE
@@ -16,15 +16,7 @@ const collectGitDiffStatsMock = mock(() => ({
insertions: 0, insertions: 0,
deletions: 0, deletions: 0,
})) }))
const formatFileChangesMock = mock(() => "No file changes")
mock.module("../../shared/session-utils", () => ({
isCallerOrchestrator: isCallerOrchestratorMock,
}))
mock.module("../../shared/git-worktree", () => ({
collectGitDiffStats: collectGitDiffStatsMock,
formatFileChanges: mock(() => "No file changes"),
}))
afterAll(() => { mock.restore() }) afterAll(() => { mock.restore() })
@@ -50,6 +42,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
isCallerOrchestratorMock.mockClear() isCallerOrchestratorMock.mockClear()
collectGitDiffStatsMock.mockClear() collectGitDiffStatsMock.mockClear()
formatFileChangesMock.mockClear()
}) })
afterEach(() => { afterEach(() => {
@@ -108,6 +101,9 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
pendingTaskRefs: new Map(), pendingTaskRefs: new Map(),
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}) })
} }
@@ -175,13 +171,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"), serverUrl: new URL("https://example.com"),
$: Bun.$, $: Bun.$,
} satisfies PluginInput } satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({ const afterHandler = createToolExecuteAfterHandler({
ctx, ctx,
pendingFilePaths, pendingFilePaths,
pendingTaskRefs, pendingTaskRefs,
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}) })
await beforeHandler( await beforeHandler(
@@ -252,13 +256,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"), serverUrl: new URL("https://example.com"),
$: Bun.$, $: Bun.$,
} satisfies PluginInput } satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({ const afterHandler = createToolExecuteAfterHandler({
ctx, ctx,
pendingFilePaths, pendingFilePaths,
pendingTaskRefs, pendingTaskRefs,
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}) })
await beforeHandler( await beforeHandler(
@@ -322,13 +334,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"), serverUrl: new URL("https://example.com"),
$: Bun.$, $: Bun.$,
} satisfies PluginInput } satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({ const afterHandler = createToolExecuteAfterHandler({
ctx, ctx,
pendingFilePaths, pendingFilePaths,
pendingTaskRefs, pendingTaskRefs,
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}) })
await beforeHandler( await beforeHandler(
@@ -393,13 +413,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"), serverUrl: new URL("https://example.com"),
$: Bun.$, $: Bun.$,
} satisfies PluginInput } satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({ const afterHandler = createToolExecuteAfterHandler({
ctx, ctx,
pendingFilePaths, pendingFilePaths,
pendingTaskRefs, pendingTaskRefs,
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}) })
await beforeHandler( await beforeHandler(
@@ -482,13 +510,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"), serverUrl: new URL("https://example.com"),
$: Bun.$, $: Bun.$,
} satisfies PluginInput } satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({ const afterHandler = createToolExecuteAfterHandler({
ctx, ctx,
pendingFilePaths, pendingFilePaths,
pendingTaskRefs, pendingTaskRefs,
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}) })
await beforeHandler( await beforeHandler(
@@ -15,15 +15,7 @@ const collectGitDiffStatsMock = mock(() => ({
insertions: 0, insertions: 0,
deletions: 0, deletions: 0,
})) }))
const formatFileChangesMock = mock(() => "No file changes")
mock.module("../../shared/session-utils", () => ({
isCallerOrchestrator: isCallerOrchestratorMock,
}))
mock.module("../../shared/git-worktree", () => ({
collectGitDiffStats: collectGitDiffStatsMock,
formatFileChanges: mock(() => "No file changes"),
}))
afterAll(() => { mock.restore() }) afterAll(() => { mock.restore() })
@@ -47,6 +39,7 @@ describe("createToolExecuteAfterHandler task timers", () => {
} }
isCallerOrchestratorMock.mockClear() isCallerOrchestratorMock.mockClear()
collectGitDiffStatsMock.mockClear() collectGitDiffStatsMock.mockClear()
formatFileChangesMock.mockClear()
}) })
afterEach(() => { afterEach(() => {
@@ -104,6 +97,7 @@ describe("createToolExecuteAfterHandler task timers", () => {
pendingFilePaths, pendingFilePaths,
pendingTaskRefs, pendingTaskRefs,
pendingPlanSnapshots, pendingPlanSnapshots,
isCallerOrchestrator: isCallerOrchestratorMock,
}), }),
afterHandler: createToolExecuteAfterHandler({ afterHandler: createToolExecuteAfterHandler({
ctx, ctx,
@@ -112,6 +106,9 @@ describe("createToolExecuteAfterHandler task timers", () => {
pendingPlanSnapshots, pendingPlanSnapshots,
autoCommit: true, autoCommit: true,
getState: () => ({ promptFailureCount: 0 }), getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}), }),
} }
} }
+6 -2
View File
@@ -129,9 +129,13 @@ export function createToolExecuteAfterHandler(input: {
autoCommit: boolean autoCommit: boolean
getState: (sessionID: string) => SessionState getState: (sessionID: string) => SessionState
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean> isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
collectGitDiffStats?: typeof collectGitDiffStats
formatFileChanges?: typeof formatFileChanges
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> { }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client)) const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
const collectGitDiffStatsImpl = input.collectGitDiffStats ?? collectGitDiffStats
const formatFileChangesImpl = input.formatFileChanges ?? formatFileChanges
return async (toolInput, toolOutput): Promise<void> => { return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035) // Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) { if (!toolOutput) {
@@ -212,8 +216,8 @@ export function createToolExecuteAfterHandler(input: {
if (toolOutput.output && typeof toolOutput.output === "string") { if (toolOutput.output && typeof toolOutput.output === "string") {
const worktreePath = boulderState?.worktree_path?.trim() const worktreePath = boulderState?.worktree_path?.trim()
const verificationDirectory = worktreePath ? worktreePath : ctx.directory const verificationDirectory = worktreePath ? worktreePath : ctx.directory
const gitStats = collectGitDiffStats(verificationDirectory) const gitStats = collectGitDiffStatsImpl(verificationDirectory)
const fileChanges = formatFileChanges(gitStats) const fileChanges = formatFileChangesImpl(gitStats)
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output) const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) { if (boulderState) {
@@ -1,4 +1,4 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginEntryInfo } from "../auto-update-checker/checker/plugin-entry" import type { PluginEntryInfo } from "../auto-update-checker/checker/plugin-entry"
@@ -13,10 +13,6 @@ const ORIGINAL_CACHE_PACKAGE_JSON = existsSync(CACHE_PACKAGE_JSON_PATH)
let importCounter = 0 let importCounter = 0
async function importFreshSyncPackageJsonModule(): Promise<typeof import("../auto-update-checker/checker/sync-package-json")> { async function importFreshSyncPackageJsonModule(): Promise<typeof import("../auto-update-checker/checker/sync-package-json")> {
mock.module("../../shared/logger", () => ({
log: () => {},
}))
return import(`../auto-update-checker/checker/sync-package-json?test=${importCounter++}`) return import(`../auto-update-checker/checker/sync-package-json?test=${importCounter++}`)
} }
@@ -253,16 +249,9 @@ describe("syncCachePackageJsonToIntent", () => {
) )
const fs = await import("node:fs") const fs = await import("node:fs")
const originalWriteFileSync = fs.writeFileSync const writeFileSyncSpy = spyOn(fs, "writeFileSync").mockImplementation(() => {
const originalRenameSync = fs.renameSync throw new Error("EACCES: permission denied")
})
mock.module("node:fs", () => ({
...fs,
writeFileSync: mock(() => {
throw new Error("EACCES: permission denied")
}),
renameSync: fs.renameSync,
}))
try { try {
const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule()
@@ -279,11 +268,7 @@ describe("syncCachePackageJsonToIntent", () => {
expect(result.synced).toBe(false) expect(result.synced).toBe(false)
expect(result.error).toBe("write_error") expect(result.error).toBe("write_error")
} finally { } finally {
mock.module("node:fs", () => ({ writeFileSyncSpy.mockRestore()
...fs,
writeFileSync: originalWriteFileSync,
renameSync: originalRenameSync,
}))
} }
}) })
}) })
@@ -299,20 +284,20 @@ describe("syncCachePackageJsonToIntent", () => {
const fs = await import("node:fs") const fs = await import("node:fs")
const originalWriteFileSync = fs.writeFileSync const originalWriteFileSync = fs.writeFileSync
const originalRenameSync = fs.renameSync
let tempFilePath: string | null = null let tempFilePath: string | null = null
mock.module("node:fs", () => ({ const writeFileSyncSpy = spyOn(fs, "writeFileSync").mockImplementation((
...fs, (file: Parameters<typeof fs.writeFileSync>[0],
writeFileSync: mock((path: string, data: string) => { data: Parameters<typeof fs.writeFileSync>[1],
tempFilePath = path options?: Parameters<typeof fs.writeFileSync>[2]) => {
return originalWriteFileSync(path, data) tempFilePath = String(file)
}), return originalWriteFileSync(file, data, options)
renameSync: mock(() => { }
throw new Error("EXDEV: cross-device link not permitted") ) as typeof fs.writeFileSync)
}), const renameSyncSpy = spyOn(fs, "renameSync").mockImplementation(() => {
})) throw new Error("EXDEV: cross-device link not permitted")
})
try { try {
const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule() const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule()
@@ -331,11 +316,8 @@ describe("syncCachePackageJsonToIntent", () => {
expect(tempFilePath).not.toBeNull() expect(tempFilePath).not.toBeNull()
expect(existsSync(tempFilePath!)).toBe(false) expect(existsSync(tempFilePath!)).toBe(false)
} finally { } finally {
mock.module("node:fs", () => ({ writeFileSyncSpy.mockRestore()
...fs, renameSyncSpy.mockRestore()
writeFileSync: originalWriteFileSync,
renameSync: originalRenameSync,
}))
} }
}) })
}) })
+35 -64
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { beforeEach, describe, expect, it, mock } from "bun:test"
import { createPluginModule } from "./index"
const mockInitConfigContext = mock(() => {}) const mockInitConfigContext = mock(() => {})
const mockInjectServerAuthIntoClient = mock(() => {}) const mockInjectServerAuthIntoClient = mock(() => {})
@@ -30,85 +31,55 @@ const mockCreateHooks = mock(() => ({
claudeCodeHooks: undefined, claudeCodeHooks: undefined,
})) }))
const mockCreatePluginInterface = mock(() => ({})) const mockCreatePluginInterface = mock(() => ({}))
function installModuleMocks(): void { const mockLog = mock(() => {})
mock.module("./cli/config-manager/config-context", () => ({
function createTestPluginModule(): ReturnType<typeof createPluginModule> {
return createPluginModule({
initConfigContext: mockInitConfigContext, initConfigContext: mockInitConfigContext,
})) injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
mock.module("./shared/external-plugin-detector", () => ({ logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning,
loadPluginConfig: mockLoadPluginConfig as never,
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never,
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never,
createManagers: mockCreateManagers as never,
createTools: mockCreateTools as never,
createHooks: mockCreateHooks as never,
createPluginInterface: mockCreatePluginInterface as never,
log: mockLog,
detectExternalSkillPlugin: mock(() => ({ detected: false, pluginName: null })), detectExternalSkillPlugin: mock(() => ({ detected: false, pluginName: null })),
getSkillPluginConflictWarning: mock(() => ""), getSkillPluginConflictWarning: mock(() => ""),
})) initializeOpenClaw: mock(async () => {}),
mock.module("./shared/logger", () => ({ startTmuxCheck: mock(() => {}),
log: mock(() => {}), createModelCacheState: mock(() => ({})) as never,
}))
mock.module("./shared/log-legacy-plugin-startup-warning", () => ({
logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning,
}))
mock.module("./shared/opencode-server-auth", () => ({
injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
}))
mock.module("./plugin-config", () => ({
loadPluginConfig: mockLoadPluginConfig,
}))
mock.module("./create-runtime-tmux-config", () => ({
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig,
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled,
}))
mock.module("./create-managers", () => ({
createManagers: mockCreateManagers,
}))
mock.module("./create-tools", () => ({
createTools: mockCreateTools,
}))
mock.module("./create-hooks", () => ({
createHooks: mockCreateHooks,
}))
mock.module("./plugin-interface", () => ({
createPluginInterface: mockCreatePluginInterface,
}))
mock.module("./plugin-state", () => ({
createModelCacheState: mock(() => ({})),
}))
mock.module("./shared/first-message-variant", () => ({
createFirstMessageVariantGate: mock(() => ({ createFirstMessageVariantGate: mock(() => ({
shouldOverride: () => false, shouldOverride: () => false,
markApplied: () => {}, markApplied: () => {},
markSessionCreated: () => {}, markSessionCreated: () => {},
clear: () => {}, clear: () => {},
})), })) as never,
})) installAgentSortShim: mock(() => {}),
mock.module("./openclaw", () => ({ setAgentSortOrder: mock(() => {}),
initializeOpenClaw: mock(async () => {}), })
}))
mock.module("./tools/interactive-bash", () => ({
interactive_bash: {},
startBackgroundCheck: mock(() => {}),
}))
mock.module("./tools/lsp/client", () => ({
lspManager: {
getClient: mock(async () => ({
diagnostics: mock(async () => ({ items: [] })),
})),
stopAll: mock(async () => {}),
releaseClient: mock(() => {}),
cleanupTempDirectoryClients: mock(async () => {}),
},
}))
} }
describe("oh-my-openagent telemetry isolation", () => { describe("oh-my-openagent telemetry isolation", () => {
beforeEach(() => { beforeEach(() => {
mock.restore() mockInitConfigContext.mockClear()
installModuleMocks() mockInjectServerAuthIntoClient.mockClear()
}) mockLogLegacyPluginStartupWarning.mockClear()
mockLoadPluginConfig.mockClear()
afterEach(() => { mockIsTmuxIntegrationEnabled.mockClear()
mock.restore() mockCreateRuntimeTmuxConfig.mockClear()
mockCreateManagers.mockClear()
mockCreateTools.mockClear()
mockCreateHooks.mockClear()
mockCreatePluginInterface.mockClear()
mockLog.mockClear()
}) })
it("does not crash plugin load when telemetry throws", async () => { it("does not crash plugin load when telemetry throws", async () => {
// given // given
const { default: plugin } = await import(`./index?telemetry=${Date.now()}-${Math.random()}`) const plugin = createTestPluginModule()
// when // when
const result = await plugin.server({ const result = await plugin.server({
+32 -85
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { beforeEach, describe, expect, it, mock } from "bun:test"
import { createPluginModule } from "./index"
const mockInitConfigContext = mock(() => {}) const mockInitConfigContext = mock(() => {})
const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null })) const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null }))
@@ -9,7 +10,6 @@ const mockLoadPluginConfig = mock(() => ({}))
const mockIsTmuxIntegrationEnabled = mock( const mockIsTmuxIntegrationEnabled = mock(
(pluginConfig: { tmux?: { enabled?: boolean } | undefined }) => pluginConfig.tmux?.enabled ?? false, (pluginConfig: { tmux?: { enabled?: boolean } | undefined }) => pluginConfig.tmux?.enabled ?? false,
) )
const mockIsInteractiveBashEnabled = mock(() => false)
const mockCreateRuntimeTmuxConfig = mock(() => ({ const mockCreateRuntimeTmuxConfig = mock(() => ({
enabled: false, enabled: false,
layout: "tiled" as const, layout: "tiled" as const,
@@ -39,95 +39,43 @@ const mockInitializeOpenClaw = mock(async () => {})
const mockStartTmuxCheck = mock(() => {}) const mockStartTmuxCheck = mock(() => {})
const mockInstallAgentSortShim = mock(() => {}) const mockInstallAgentSortShim = mock(() => {})
const mockSetAgentSortOrder = mock(() => {}) const mockSetAgentSortOrder = mock(() => {})
const mockLog = mock(() => {})
const mockCreateModelCacheState = mock(() => ({}))
const mockCreateFirstMessageVariantGate = mock(() => ({
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
}))
let pluginModule: (typeof import("./index"))["default"] let pluginModule: ReturnType<typeof createPluginModule>
function installIndexModuleMocks(): void { function createTestPluginModule(): ReturnType<typeof createPluginModule> {
mock.module("./cli/config-manager/config-context", () => ({ return createPluginModule({
initConfigContext: mockInitConfigContext, initConfigContext: mockInitConfigContext,
}))
mock.module("./shared/external-plugin-detector", () => ({
detectExternalSkillPlugin: mockDetectExternalSkillPlugin, detectExternalSkillPlugin: mockDetectExternalSkillPlugin,
getSkillPluginConflictWarning: mockGetSkillPluginConflictWarning, getSkillPluginConflictWarning: mockGetSkillPluginConflictWarning,
}))
mock.module("./shared/logger", () => ({
log: mock(() => {}),
}))
mock.module("./shared/log-legacy-plugin-startup-warning", () => ({
logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning,
}))
mock.module("./shared/opencode-server-auth", () => ({
injectServerAuthIntoClient: mockInjectServerAuthIntoClient, injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
})) logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning,
loadPluginConfig: mockLoadPluginConfig as never,
mock.module("./plugin-config", () => ({ isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never,
loadPluginConfig: mockLoadPluginConfig, createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never,
})) createManagers: mockCreateManagers as never,
createTools: mockCreateTools as never,
mock.module("./create-runtime-tmux-config", () => ({ createHooks: mockCreateHooks as never,
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig, createPluginInterface: mockCreatePluginInterface as never,
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled, initializeOpenClaw: mockInitializeOpenClaw as never,
isInteractiveBashEnabled: mockIsInteractiveBashEnabled, startTmuxCheck: mockStartTmuxCheck,
}))
mock.module("./create-managers", () => ({
createManagers: mockCreateManagers,
}))
mock.module("./create-tools", () => ({
createTools: mockCreateTools,
}))
mock.module("./create-hooks", () => ({
createHooks: mockCreateHooks,
}))
mock.module("./plugin-interface", () => ({
createPluginInterface: mockCreatePluginInterface,
}))
mock.module("./plugin-state", () => ({
createModelCacheState: mock(() => ({})),
}))
mock.module("./shared/first-message-variant", () => ({
createFirstMessageVariantGate: mock(() => ({
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
})),
}))
mock.module("./shared/agent-sort-shim", () => ({
installAgentSortShim: mockInstallAgentSortShim, installAgentSortShim: mockInstallAgentSortShim,
setAgentSortOrder: mockSetAgentSortOrder, setAgentSortOrder: mockSetAgentSortOrder,
})) log: mockLog,
createModelCacheState: mockCreateModelCacheState as never,
mock.module("./openclaw", () => ({ createFirstMessageVariantGate: mockCreateFirstMessageVariantGate as never,
initializeOpenClaw: mockInitializeOpenClaw, })
}))
mock.module("./tools/interactive-bash", () => ({
interactive_bash: {},
startBackgroundCheck: mockStartTmuxCheck,
}))
}
async function importFreshIndexModule(): Promise<typeof import("./index")> {
return import(`./index?test=${Date.now()}-${Math.random()}`)
} }
describe("oh-my-openagent plugin module", () => { describe("oh-my-openagent plugin module", () => {
beforeEach(async () => { beforeEach(() => {
mock.restore()
installIndexModuleMocks()
;({ default: pluginModule } = await importFreshIndexModule())
mockInitConfigContext.mockClear() mockInitConfigContext.mockClear()
mockDetectExternalSkillPlugin.mockClear() mockDetectExternalSkillPlugin.mockClear()
mockGetSkillPluginConflictWarning.mockClear() mockGetSkillPluginConflictWarning.mockClear()
@@ -135,7 +83,6 @@ describe("oh-my-openagent plugin module", () => {
mockLogLegacyPluginStartupWarning.mockClear() mockLogLegacyPluginStartupWarning.mockClear()
mockLoadPluginConfig.mockClear() mockLoadPluginConfig.mockClear()
mockIsTmuxIntegrationEnabled.mockClear() mockIsTmuxIntegrationEnabled.mockClear()
mockIsInteractiveBashEnabled.mockClear()
mockCreateRuntimeTmuxConfig.mockClear() mockCreateRuntimeTmuxConfig.mockClear()
mockCreateManagers.mockClear() mockCreateManagers.mockClear()
mockCreateTools.mockClear() mockCreateTools.mockClear()
@@ -145,10 +92,10 @@ describe("oh-my-openagent plugin module", () => {
mockStartTmuxCheck.mockClear() mockStartTmuxCheck.mockClear()
mockInstallAgentSortShim.mockClear() mockInstallAgentSortShim.mockClear()
mockSetAgentSortOrder.mockClear() mockSetAgentSortOrder.mockClear()
}) mockLog.mockClear()
mockCreateModelCacheState.mockClear()
afterEach(() => { mockCreateFirstMessageVariantGate.mockClear()
mock.restore() pluginModule = createTestPluginModule()
}) })
it("starts openclaw during plugin bootstrap when openclaw config exists", async () => { it("starts openclaw during plugin bootstrap when openclaw config exists", async () => {
+132 -83
View File
@@ -29,108 +29,157 @@ type HooksWithCompactionAutocontinue = Hooks & {
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook "experimental.compaction.autocontinue"?: CompactionAutocontinueHook
} }
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => { type PluginModuleDeps = {
installAgentSortShim() initConfigContext: typeof initConfigContext
initConfigContext("opencode", null) installAgentSortShim: typeof installAgentSortShim
log("[oh-my-openagent] ENTRY - plugin loading", { setAgentSortOrder: typeof setAgentSortOrder
directory: input.directory, log: typeof log
}) logLegacyPluginStartupWarning: typeof logLegacyPluginStartupWarning
logLegacyPluginStartupWarning() detectExternalSkillPlugin: typeof detectExternalSkillPlugin
getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning
injectServerAuthIntoClient: typeof injectServerAuthIntoClient
loadPluginConfig: typeof loadPluginConfig
initializeOpenClaw: typeof initializeOpenClaw
isTmuxIntegrationEnabled: typeof isTmuxIntegrationEnabled
startTmuxCheck: typeof startTmuxCheck
createFirstMessageVariantGate: typeof createFirstMessageVariantGate
createRuntimeTmuxConfig: typeof createRuntimeTmuxConfig
createModelCacheState: typeof createModelCacheState
createManagers: typeof createManagers
createTools: typeof createTools
createHooks: typeof createHooks
createPluginInterface: typeof createPluginInterface
}
const skillPluginCheck = detectExternalSkillPlugin(input.directory) const defaultPluginModuleDeps: PluginModuleDeps = {
if (skillPluginCheck.detected && skillPluginCheck.pluginName) { initConfigContext,
console.warn(getSkillPluginConflictWarning(skillPluginCheck.pluginName)) installAgentSortShim,
} setAgentSortOrder,
log,
logLegacyPluginStartupWarning,
detectExternalSkillPlugin,
getSkillPluginConflictWarning,
injectServerAuthIntoClient,
loadPluginConfig,
initializeOpenClaw,
isTmuxIntegrationEnabled,
startTmuxCheck,
createFirstMessageVariantGate,
createRuntimeTmuxConfig,
createModelCacheState,
createManagers,
createTools,
createHooks,
createPluginInterface,
}
injectServerAuthIntoClient(input.client) export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): PluginModule {
const deps = { ...defaultPluginModuleDeps, ...overrides }
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
deps.installAgentSortShim()
deps.initConfigContext("opencode", null)
deps.log("[oh-my-openagent] ENTRY - plugin loading", {
directory: input.directory,
})
deps.logLegacyPluginStartupWarning()
const pluginConfig = loadPluginConfig(input.directory, input) const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory)
setAgentSortOrder(pluginConfig.agent_order) if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
console.warn(deps.getSkillPluginConflictWarning(skillPluginCheck.pluginName))
if (pluginConfig.openclaw) {
await initializeOpenClaw(pluginConfig.openclaw)
}
if (pluginConfig.team_mode?.enabled) {
const teamModeConfig = pluginConfig.team_mode
try {
const { ensureBaseDirs, resolveBaseDir } = await import("./features/team-mode/team-registry/paths")
const { checkTeamModeDependencies } = await import("./features/team-mode/deps")
await checkTeamModeDependencies(teamModeConfig)
await ensureBaseDirs(resolveBaseDir(teamModeConfig))
if (pluginConfig.disabled_skills?.includes("team-mode")) {
console.warn(
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
)
}
} catch (err) {
console.warn("[team-mode] init failed:", err)
} }
}
const tmuxIntegrationEnabled = isTmuxIntegrationEnabled(pluginConfig)
if (tmuxIntegrationEnabled) {
startTmuxCheck()
}
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName) deps.injectServerAuthIntoClient(input.client)
const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true
const firstMessageVariantGate = createFirstMessageVariantGate() const pluginConfig = deps.loadPluginConfig(input.directory, input)
deps.setAgentSortOrder(pluginConfig.agent_order)
const tmuxConfig = createRuntimeTmuxConfig(pluginConfig) if (pluginConfig.openclaw) {
await deps.initializeOpenClaw(pluginConfig.openclaw)
}
if (pluginConfig.team_mode?.enabled) {
const teamModeConfig = pluginConfig.team_mode
try {
const { ensureBaseDirs, resolveBaseDir } = await import("./features/team-mode/team-registry/paths")
const { checkTeamModeDependencies } = await import("./features/team-mode/deps")
await checkTeamModeDependencies(teamModeConfig)
await ensureBaseDirs(resolveBaseDir(teamModeConfig))
if (pluginConfig.disabled_skills?.includes("team-mode")) {
console.warn(
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
)
}
} catch (err) {
console.warn("[team-mode] init failed:", err)
}
}
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
if (tmuxIntegrationEnabled) {
deps.startTmuxCheck()
}
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
const modelCacheState = createModelCacheState() const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName)
const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true
const managers = createManagers({ const firstMessageVariantGate = deps.createFirstMessageVariantGate()
ctx: input,
pluginConfig,
tmuxConfig,
modelCacheState,
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
})
const toolsResult = await createTools({ const tmuxConfig = deps.createRuntimeTmuxConfig(pluginConfig)
ctx: input,
pluginConfig,
managers,
})
const hooks = createHooks({ const modelCacheState = deps.createModelCacheState()
ctx: input,
pluginConfig,
modelCacheState,
backgroundManager: managers.backgroundManager,
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
})
const pluginInterface = createPluginInterface({ const managers = deps.createManagers({
ctx: input, ctx: input,
pluginConfig, pluginConfig,
firstMessageVariantGate, tmuxConfig,
managers, modelCacheState,
hooks, backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
tools: toolsResult.filteredTools, })
})
const pluginHooks: HooksWithCompactionAutocontinue = { const toolsResult = await deps.createTools({
...pluginInterface, ctx: input,
pluginConfig,
managers,
})
"experimental.session.compacting": createSessionCompactingHandler(hooks), const hooks = deps.createHooks({
ctx: input,
pluginConfig,
modelCacheState,
backgroundManager: managers.backgroundManager,
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
})
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks), const pluginInterface = deps.createPluginInterface({
ctx: input,
pluginConfig,
firstMessageVariantGate,
managers,
hooks,
tools: toolsResult.filteredTools,
})
const pluginHooks: HooksWithCompactionAutocontinue = {
...pluginInterface,
"experimental.session.compacting": createSessionCompactingHandler(hooks),
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
}
return pluginHooks
} }
return pluginHooks return {
id: "oh-my-openagent",
server: serverPlugin,
}
} }
const pluginModule: PluginModule = { const pluginModule: PluginModule = createPluginModule()
id: "oh-my-openagent",
server: serverPlugin,
}
export default pluginModule export default pluginModule
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process" import * as childProcess from "node:child_process"
import { readFileSync } from "node:fs" import * as fs from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { parseGitStatusPorcelain } from "./parse-status-porcelain" import { parseGitStatusPorcelain } from "./parse-status-porcelain"
import { parseGitDiffNumstat } from "./parse-diff-numstat" import { parseGitDiffNumstat } from "./parse-diff-numstat"
@@ -7,21 +7,21 @@ import type { GitFileStat } from "./types"
export function collectGitDiffStats(directory: string): GitFileStat[] { export function collectGitDiffStats(directory: string): GitFileStat[] {
try { try {
const diffOutput = execFileSync("git", ["diff", "--numstat", "HEAD"], { const diffOutput = childProcess.execFileSync("git", ["diff", "--numstat", "HEAD"], {
cwd: directory, cwd: directory,
encoding: "utf-8", encoding: "utf-8",
timeout: 5000, timeout: 5000,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}).trimEnd() }).trimEnd()
const statusOutput = execFileSync("git", ["status", "--porcelain"], { const statusOutput = childProcess.execFileSync("git", ["status", "--porcelain"], {
cwd: directory, cwd: directory,
encoding: "utf-8", encoding: "utf-8",
timeout: 5000, timeout: 5000,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}).trimEnd() }).trimEnd()
const untrackedOutput = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { const untrackedOutput = childProcess.execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: directory, cwd: directory,
encoding: "utf-8", encoding: "utf-8",
timeout: 5000, timeout: 5000,
@@ -34,7 +34,7 @@ export function collectGitDiffStats(directory: string): GitFileStat[] {
.filter(Boolean) .filter(Boolean)
.map((filePath) => { .map((filePath) => {
try { try {
const content = readFileSync(join(directory, filePath), "utf-8") const content = fs.readFileSync(join(directory, filePath), "utf-8")
const lineCount = content.split("\n").length - (content.endsWith("\n") ? 1 : 0) const lineCount = content.split("\n").length - (content.endsWith("\n") ? 1 : 0)
return `${lineCount}\t0\t${filePath}` return `${lineCount}\t0\t${filePath}`
} catch { } catch {
+8 -8
View File
@@ -1,4 +1,4 @@
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs" import * as fs from "node:fs"
import { applyEdits, modify } from "jsonc-parser" import { applyEdits, modify } from "jsonc-parser"
@@ -36,10 +36,10 @@ function updateJsoncPluginArray(content: string, pluginEntries: string[]): strin
} }
export function migrateLegacyPluginEntry(configPath: string): boolean { export function migrateLegacyPluginEntry(configPath: string): boolean {
if (!existsSync(configPath)) return false if (!fs.existsSync(configPath)) return false
try { try {
const content = readFileSync(configPath, "utf-8") const content = fs.readFileSync(configPath, "utf-8")
if (!content.includes(LEGACY_PLUGIN_NAME)) return false if (!content.includes(LEGACY_PLUGIN_NAME)) return false
const parseResult = parseJsoncSafe<OpenCodeConfig>(content) const parseResult = parseJsoncSafe<OpenCodeConfig>(content)
@@ -53,15 +53,15 @@ export function migrateLegacyPluginEntry(configPath: string): boolean {
if (!updated || updated === content) return false if (!updated || updated === content) return false
const tempPath = `${configPath}.tmp` const tempPath = `${configPath}.tmp`
writeFileSync(tempPath, updated, "utf-8") fs.writeFileSync(tempPath, updated, "utf-8")
const tempFileDescriptor = openSync(tempPath, "r+") const tempFileDescriptor = fs.openSync(tempPath, "r+")
try { try {
fsyncSync(tempFileDescriptor) fs.fsyncSync(tempFileDescriptor)
} finally { } finally {
closeSync(tempFileDescriptor) fs.closeSync(tempFileDescriptor)
} }
renameSync(tempPath, configPath) fs.renameSync(tempPath, configPath)
log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", { log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", {
configPath, configPath,
from: LEGACY_PLUGIN_NAME, from: LEGACY_PLUGIN_NAME,
@@ -1,6 +1,6 @@
/// <reference path="../../../bun-test.d.ts" /> /// <reference path="../../../bun-test.d.ts" />
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
@@ -68,14 +68,9 @@ describe("migrateLegacyPluginEntry", () => {
writeFileSync(configPath, originalContent) writeFileSync(configPath, originalContent)
const fs = await import("node:fs") const fs = await import("node:fs")
const originalRenameSync = fs.renameSync const renameSyncSpy = spyOn(fs, "renameSync").mockImplementation(() => {
throw new Error("simulated rename failure")
mock.module("node:fs", () => ({ })
...fs,
renameSync: () => {
throw new Error("simulated rename failure")
},
}))
try { try {
const { migrateLegacyPluginEntry } = await importFreshMigrationModule() const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
@@ -87,10 +82,7 @@ describe("migrateLegacyPluginEntry", () => {
expect(readFileSync(tempPath, "utf-8")).toContain("oh-my-openagent@latest") expect(readFileSync(tempPath, "utf-8")).toContain("oh-my-openagent@latest")
expect(readFileSync(tempPath, "utf-8")).not.toContain("oh-my-opencode") expect(readFileSync(tempPath, "utf-8")).not.toContain("oh-my-opencode")
} finally { } finally {
mock.module("node:fs", () => ({ renameSyncSpy.mockRestore()
...fs,
renameSync: originalRenameSync,
}))
} }
}) })
}) })
@@ -106,13 +98,13 @@ describe("migrateLegacyPluginEntry", () => {
const originalOpenSync = fs.openSync const originalOpenSync = fs.openSync
const openSyncCalls: string[] = [] const openSyncCalls: string[] = []
mock.module("node:fs", () => ({ const openSyncSpy = spyOn(fs, "openSync").mockImplementation((
...fs, path: Parameters<typeof fs.openSync>[0],
openSync: (path: Parameters<typeof fs.openSync>[0], flags: Parameters<typeof fs.openSync>[1]) => { flags: Parameters<typeof fs.openSync>[1],
) => {
openSyncCalls.push(String(flags)) openSyncCalls.push(String(flags))
return originalOpenSync(path, flags) return originalOpenSync(path, flags)
}, })
}))
try { try {
const { migrateLegacyPluginEntry } = await importFreshMigrationModule() const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
@@ -122,10 +114,7 @@ describe("migrateLegacyPluginEntry", () => {
expect(result).toBe(true) expect(result).toBe(true)
expect(openSyncCalls).toContain("r+") expect(openSyncCalls).toContain("r+")
} finally { } finally {
mock.module("node:fs", () => ({ openSyncSpy.mockRestore()
...fs,
openSync: originalOpenSync,
}))
} }
}) })
}) })
+3 -3
View File
@@ -1,7 +1,7 @@
import type { OpencodeClient } from "./types" import type { OpencodeClient } from "./types"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { isRecord } from "../../shared/record-type-guard" import { isRecord } from "../../shared/record-type-guard"
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache" import * as connectedProvidersCache from "../../shared/connected-providers-cache"
type ModelListClient = OpencodeClient & { type ModelListClient = OpencodeClient & {
model: { list: () => Promise<unknown> } model: { list: () => Promise<unknown> }
@@ -34,7 +34,7 @@ function addFromProviderModels(
} }
export async function getAvailableModelsForDelegateTask(client: OpencodeClient): Promise<Set<string>> { export async function getAvailableModelsForDelegateTask(client: OpencodeClient): Promise<Set<string>> {
const providerModelsCache = readProviderModelsCache() const providerModelsCache = connectedProvidersCache.readProviderModelsCache()
if (providerModelsCache?.models) { if (providerModelsCache?.models) {
const connected = new Set(providerModelsCache.connected) const connected = new Set(providerModelsCache.connected)
@@ -47,7 +47,7 @@ export async function getAvailableModelsForDelegateTask(client: OpencodeClient):
return out return out
} }
const connectedProviders = readConnectedProvidersCache() const connectedProviders = connectedProvidersCache.readConnectedProvidersCache()
if (!connectedProviders || connectedProviders.length === 0) { if (!connectedProviders || connectedProviders.length === 0) {
return new Set() return new Set()
+7 -3
View File
@@ -2,7 +2,7 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { normalizeModel } from "../../shared/model-normalization" import { normalizeModel } from "../../shared/model-normalization"
import { fuzzyMatchModel } from "../../shared/model-availability" import { fuzzyMatchModel } from "../../shared/model-availability"
import { transformModelForProvider } from "../../shared/provider-model-id-transform" import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache" import * as connectedProvidersCache from "../../shared/connected-providers-cache"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser" import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser"
@@ -63,11 +63,15 @@ export function resolveModelForDelegateTask(input: {
return { model: userModel } return { model: userModel }
} }
const connectedProviders = input.availableModels.size === 0 ? readConnectedProvidersCache() : null const connectedProviders = input.availableModels.size === 0 ? connectedProvidersCache.readConnectedProvidersCache() : null
// Before provider cache is created (first run), skip model resolution entirely. // Before provider cache is created (first run), skip model resolution entirely.
// OpenCode will use its system default model when no model is specified in the prompt. // OpenCode will use its system default model when no model is specified in the prompt.
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) { if (
input.availableModels.size === 0 &&
!connectedProvidersCache.hasProviderModelsCache() &&
!connectedProvidersCache.hasConnectedProvidersCache()
) {
return { skipped: true } return { skipped: true }
} }