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