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 { 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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user