Compare commits

...

14 Commits

Author SHA1 Message Date
YeonGyu-Kim
271929a9e4 ci: restore mock.module() overrides in afterAll to prevent cross-file pollution
Add afterAll hooks that restore original module implementations after
mock.module() overrides. This prevents mock state from leaking across
test files when bun runs them in the same process. Pattern: capture
original module with await import() before mocking, restore in afterAll.
2026-02-14 16:19:55 +09:00
YeonGyu-Kim
945329e261 fix: prevent node:fs mock pollution in directory injector tests
Move mock.module() calls from top-level to beforeEach and restore in
afterEach to prevent readFileSync mock from leaking into other test
files. Use dynamic import with cache-busting query to get fresh modules.
2026-02-14 16:19:40 +09:00
YeonGyu-Kim
f27733eae2 fix: correct test type casts, timeouts, and mock structures
- Fix PluginInput type casts to use 'as unknown as PluginInput'
- Add explicit TodoSnapshot type annotations
- Add timeouts to slow todo-continuation-enforcer tests
- Remove unnecessary test storage mocks in atlas and prometheus-md-only
- Restructure sync-executor mocks to use beforeEach/afterEach pattern
2026-02-14 16:19:29 +09:00
YeonGyu-Kim
e9c9cb696d fix: resolve symlinks in skill config source discovery and test paths
Use fs.realpath() in config-source-discovery to resolve symlinks before
loading skills, preventing duplicate/mismatched paths on systems where
tmpdir() returns a symlink (e.g., macOS /var → /private/var). Also adds
agents-config-dir utility for ~/.agents path resolution.
2026-02-14 16:19:18 +09:00
YeonGyu-Kim
3de05f6442 fix: apply parentTools in all parent session notification paths
Both parent-session-notifier.ts and notify-parent-session.ts now include
parentTools in the promptAsync body, ensuring tool restrictions are
consistently applied across all notification code paths.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
8514906c3d fix: inherit parent session tool restrictions in background task notifications
Pass parentTools from session-tools-store through the background task
lifecycle (launch → task → notify) so that when notifyParentSession
sends promptAsync, the original tool restrictions (e.g., question: false)
are preserved. This prevents the Question tool from re-enabling after
call_omo_agent background tasks complete.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
f20e1aa0d0 feat: store tool restrictions in session-tools-store at prompt-send sites
Call setSessionTools(sessionID, tools) before every prompt dispatch so
the tools object is captured and available for later retrieval when
background tasks complete.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
936b51de79 feat: add parentTools field to BackgroundTask, LaunchInput, ResumeInput
Allows background tasks to carry the parent session's tool restriction
map so it can be applied when notifying the parent session on completion.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
38a4bbc75f feat: add session-tools-store for tracking tool restrictions per session
In-memory Map-based store that records tool restriction objects (e.g.,
question: false) by sessionID when prompts are sent. This enables
retrieving the original session's tool parameters when background tasks
complete and need to notify the parent session.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
daf011c616 fix(ci): isolate loader.test.ts to prevent CWD deletion contamination
loader.test.ts creates and deletes temp directories via process.chdir()
which causes 'current working directory was deleted' errors for subsequent
tests running in the same process. Move it to isolated step and enumerate
remaining skill-loader test files individually.
2026-02-14 14:54:28 +09:00
YeonGyu-Kim
c8bc267127 fix(ci): isolate all mock-heavy test files from remaining test step
formatter.test.ts, format-default.test.ts, sync-executor.test.ts, and
session-creator.test.ts use mock.module() which pollutes bun's module
cache. Previously they ran both in the isolated step AND again in the
remaining tests step (via src/cli and src/tools wildcards), causing
cross-file contamination failures.

Now the remaining tests step enumerates subdirectories explicitly,
excluding the 4 mock-heavy files that are already run in isolation.
2026-02-14 14:39:53 +09:00
YeonGyu-Kim
c41b38990c ci: isolate mock-heavy tests to prevent cross-file module pollution
formatter.test.ts mocks format-default module, contaminating
format-default.test.ts. sync-executor.test.ts mocks session.create,
contaminating session-creator.test.ts. Run both in isolated processes.
2026-02-14 14:15:59 +09:00
YeonGyu-Kim
a4a5502e61 Merge pull request #1799 from bvanderhorn/fix/resolve-symlink-realpath
fix: use fs.realpath for symlink resolution (fixes #1738)
2026-02-14 13:46:04 +09:00
Bram van der Horn
1511886c0c fix: use fs.realpath instead of manual path.resolve for symlink resolution
resolveSymlink and resolveSymlinkAsync incorrectly resolved relative
symlinks by using path.resolve(filePath, '..', linkTarget). This fails
when symlinks use multi-level relative paths (e.g. ../../skills/...) or
when symlinks are chained (symlink pointing to a directory containing
more symlinks).

Replace with fs.realpathSync/fs.realpath which delegates to the OS for
correct resolution of all symlink types: relative, absolute, chained,
and nested.

Fixes #1738

AI-assisted-by: claude-opus-4.6 via opencode
AI-contribution: partial
AI-session: 20260212-120629-4gTXvDGV
2026-02-12 12:12:40 +01:00
48 changed files with 650 additions and 158 deletions

View File

@@ -52,12 +52,31 @@ jobs:
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
bun test src/cli/doctor/formatter.test.ts
bun test src/cli/doctor/format-default.test.ts
bun test src/tools/call-omo-agent/sync-executor.test.ts
bun test src/tools/call-omo-agent/session-creator.test.ts
bun test src/features/opencode-skill-loader/loader.test.ts
- name: Run remaining tests
run: |
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
# that were already run in isolation above.
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
bun test bin script src/config src/mcp src/index.test.ts \
src/agents src/shared \
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
src/cli/config-manager.test.ts \
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
src/tools/glob src/tools/grep src/tools/interactive-bash \
src/tools/look-at src/tools/lsp src/tools/session-manager \
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
src/tools/call-omo-agent/background-agent-executor.test.ts \
src/tools/call-omo-agent/background-executor.test.ts \
src/tools/call-omo-agent/subagent-session-creator.test.ts \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -70,7 +89,11 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader \
src/features/opencode-skill-loader/config-source-discovery.test.ts \
src/features/opencode-skill-loader/merger.test.ts \
src/features/opencode-skill-loader/skill-content.test.ts \
src/features/opencode-skill-loader/blocking.test.ts \
src/features/opencode-skill-loader/async-loader.test.ts \
src/features/skill-mcp-manager
typecheck:

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { afterEach, afterAll, describe, expect, it, mock } from "bun:test"
import type { DoctorResult } from "./types"
const realFormatDefault = await import("./format-default")
const realFormatStatus = await import("./format-status")
const realFormatVerbose = await import("./format-verbose")
function createDoctorResult(): DoctorResult {
return {
results: [
@@ -44,6 +48,12 @@ describe("formatter", () => {
mock.restore()
})
afterAll(() => {
mock.module("./format-default", () => ({ ...realFormatDefault }))
mock.module("./format-status", () => ({ ...realFormatStatus }))
mock.module("./format-verbose", () => ({ ...realFormatVerbose }))
})
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
//#given

View File

@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { afterEach, afterAll, describe, expect, it, mock } from "bun:test"
import type { CheckDefinition, CheckResult, DoctorResult, SystemInfo, ToolsSummary } from "./types"
const realChecks = await import("./checks")
const realFormatter = await import("./formatter")
function createSystemInfo(): SystemInfo {
return {
opencodeVersion: "1.0.200",
@@ -47,6 +50,11 @@ describe("runner", () => {
mock.restore()
})
afterAll(() => {
mock.module("./checks", () => ({ ...realChecks }))
mock.module("./formatter", () => ({ ...realFormatter }))
})
describe("runCheck", () => {
it("returns fail result with issue when check throws", async () => {
//#given

View File

@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
const realMcpOauthProvider = await import("../../features/mcp-oauth/provider")
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token", expiresAt: 1710000000 }))
@@ -11,6 +13,10 @@ mock.module("../../features/mcp-oauth/provider", () => ({
},
}))
afterAll(() => {
mock.module("../../features/mcp-oauth/provider", () => ({ ...realMcpOauthProvider }))
})
const { login } = await import("./login")
describe("login command", () => {

View File

@@ -1,10 +1,13 @@
import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"
import type { RunResult } from "./types"
import { createJsonOutputManager } from "./json-output"
import { resolveSession } from "./session-resolver"
import { executeOnCompleteHook } from "./on-complete-hook"
import type { OpencodeClient } from "./types"
const realSdk = await import("@opencode-ai/sdk")
const realPortUtils = await import("../../shared/port-utils")
const mockServerClose = mock(() => {})
const mockCreateOpencode = mock(() =>
Promise.resolve({
@@ -17,16 +20,23 @@ const mockIsPortAvailable = mock(() => Promise.resolve(true))
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 9999, wasAutoSelected: false }))
mock.module("@opencode-ai/sdk", () => ({
...realSdk,
createOpencode: mockCreateOpencode,
createOpencodeClient: mockCreateOpencodeClient,
}))
mock.module("../../shared/port-utils", () => ({
...realPortUtils,
isPortAvailable: mockIsPortAvailable,
getAvailableServerPort: mockGetAvailableServerPort,
DEFAULT_SERVER_PORT: 4096,
}))
afterAll(() => {
mock.module("@opencode-ai/sdk", () => ({ ...realSdk }))
mock.module("../../shared/port-utils", () => ({ ...realPortUtils }))
})
const { createServerConnection } = await import("./server-connection")
interface MockWriteStream {

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test"
const realSdk = await import("@opencode-ai/sdk")
const realPortUtils = await import("../../shared/port-utils")
const originalConsole = globalThis.console
@@ -15,16 +18,23 @@ const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasA
const mockConsoleLog = mock(() => {})
mock.module("@opencode-ai/sdk", () => ({
...realSdk,
createOpencode: mockCreateOpencode,
createOpencodeClient: mockCreateOpencodeClient,
}))
mock.module("../../shared/port-utils", () => ({
...realPortUtils,
isPortAvailable: mockIsPortAvailable,
getAvailableServerPort: mockGetAvailableServerPort,
DEFAULT_SERVER_PORT: 4096,
}))
afterAll(() => {
mock.module("@opencode-ai/sdk", () => ({ ...realSdk }))
mock.module("../../shared/port-utils", () => ({ ...realPortUtils }))
})
const { createServerConnection } = await import("./server-connection")
describe("createServerConnection", () => {

View File

@@ -7,6 +7,7 @@ import type {
} from "./types"
import { TaskHistory } from "./task-history"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
@@ -141,6 +142,7 @@ export class BackgroundManager {
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
category: input.category,
}
@@ -328,12 +330,16 @@ export class BackgroundManager {
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -535,6 +541,9 @@ export class BackgroundManager {
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.parentAgent = input.parentAgent
if (input.parentTools) {
existingTask.parentTools = input.parentTools
}
// Reset startedAt on resume to prevent immediate completion
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
existingTask.startedAt = new Date()
@@ -588,12 +597,16 @@ export class BackgroundManager {
agent: existingTask.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(existingTask.sessionID!, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -1252,6 +1265,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -148,6 +148,7 @@ export async function notifyParentSession(args: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -71,6 +71,7 @@ export async function notifyParentSession(
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -13,6 +13,7 @@ export function createTask(input: LaunchInput): BackgroundTask {
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
}
}

View File

@@ -1,5 +1,6 @@
import type { BackgroundTask, ResumeInput } from "../types"
import { log, getAgentToolRestrictions } from "../../../shared"
import { setSessionTools } from "../../../shared/session-tools-store"
import type { SpawnerContext } from "./spawner-context"
import { subagentSessions } from "../../claude-code-session-state"
import { getTaskToastManager } from "../../task-toast-manager"
@@ -35,6 +36,9 @@ export async function resumeTask(
task.parentMessageID = input.parentMessageID
task.parentModel = input.parentModel
task.parentAgent = input.parentAgent
if (input.parentTools) {
task.parentTools = input.parentTools
}
task.startedAt = new Date()
task.progress = {
@@ -75,12 +79,16 @@ export async function resumeTask(
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(task.sessionID!, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
})

View File

@@ -1,5 +1,6 @@
import type { QueueItem } from "../constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared"
import { setSessionTools } from "../../../shared/session-tools-store"
import { subagentSessions } from "../../claude-code-session-state"
import { getTaskToastManager } from "../../task-toast-manager"
import { createBackgroundSession } from "./background-session-creator"
@@ -79,12 +80,16 @@ export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise<v
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error: unknown) => {

View File

@@ -37,6 +37,8 @@ export interface BackgroundTask {
concurrencyGroup?: string
/** Parent session's agent name for notification */
parentAgent?: string
/** Parent session's tool restrictions for notification prompts */
parentTools?: Record<string, boolean>
/** Marks if the task was launched from an unstable agent/category */
isUnstableAgent?: boolean
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
@@ -56,6 +58,7 @@ export interface LaunchInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
model?: { providerID: string; modelID: string; variant?: string }
isUnstableAgent?: boolean
skills?: string[]
@@ -70,4 +73,5 @@ export interface ResumeInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
}

View File

@@ -6,15 +6,20 @@ import { tmpdir } from "os"
const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
const TEST_HOME = join(TEST_DIR, "home")
const realOs = await import("os")
const realShared = await import("../../shared")
describe("getSystemMcpServerNames", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEST_HOME, { recursive: true })
mock.module("os", () => ({
...realOs,
homedir: () => TEST_HOME,
tmpdir,
}))
mock.module("../../shared", () => ({
...realShared,
getClaudeConfigDir: () => join(TEST_HOME, ".claude"),
}))
})
@@ -22,6 +27,9 @@ describe("getSystemMcpServerNames", () => {
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
mock.module("os", () => ({ ...realOs }))
mock.module("../../shared", () => ({ ...realShared }))
})
it("returns empty set when no .mcp.json files exist", async () => {

View File

@@ -53,26 +53,28 @@ async function loadSourcePath(options: {
const stat = await fs.stat(absolutePath).catch(() => null)
if (!stat) return []
const realBasePath = await fs.realpath(absolutePath).catch(() => absolutePath)
if (stat.isFile()) {
if (!isMarkdownPath(absolutePath)) return []
if (!isMarkdownPath(realBasePath)) return []
const loaded = await loadSkillFromPath({
skillPath: absolutePath,
resolvedPath: dirname(absolutePath),
defaultName: inferSkillNameFromFileName(absolutePath),
skillPath: realBasePath,
resolvedPath: dirname(realBasePath),
defaultName: inferSkillNameFromFileName(realBasePath),
scope: "config",
})
if (!loaded) return []
return filterByGlob([loaded], dirname(absolutePath), options.globPattern)
return filterByGlob([loaded], dirname(realBasePath), options.globPattern)
}
if (!stat.isDirectory()) return []
const directorySkills = await loadSkillsFromDir({
skillsDir: absolutePath,
skillsDir: realBasePath,
scope: "config",
maxDepth: options.recursive ? MAX_RECURSIVE_DEPTH : 0,
})
return filterByGlob(directorySkills, absolutePath, options.globPattern)
return filterByGlob(directorySkills, realBasePath, options.globPattern)
}
export async function discoverConfigSourceSkills(options: {

View File

@@ -1,8 +1,13 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
import { describe, it, expect, beforeEach, afterEach, mock, spyOn, afterAll } from "bun:test"
import { SkillMcpManager } from "./manager"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
const realStreamableHttp = await import(
"@modelcontextprotocol/sdk/client/streamableHttp.js"
)
const realMcpOauthProvider = await import("../mcp-oauth/provider")
// Mock the MCP SDK transports to avoid network calls
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
const mockHttpClose = mock(() => Promise.resolve())
@@ -37,6 +42,13 @@ mock.module("../mcp-oauth/provider", () => ({
},
}))
afterAll(() => {
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
...realStreamableHttp,
}))
mock.module("../mcp-oauth/provider", () => ({ ...realMcpOauthProvider }))
})

View File

@@ -1,9 +1,13 @@
import { describe, test, expect, mock, beforeEach } from 'bun:test'
import { describe, test, expect, mock, beforeEach, afterAll } from 'bun:test'
import type { TmuxConfig } from '../../config/schema'
import type { WindowState, PaneAction } from './types'
import type { ActionResult, ExecuteContext } from './action-executor'
import type { TmuxUtilDeps } from './manager'
const realPaneStateQuerier = await import('./pane-state-querier')
const realActionExecutor = await import('./action-executor')
const realSharedTmux = await import('../../shared/tmux')
type ExecuteActionsResult = {
success: boolean
spawnedPaneId?: string
@@ -71,6 +75,12 @@ mock.module('../../shared/tmux', () => {
}
})
afterAll(() => {
mock.module('./pane-state-querier', () => ({ ...realPaneStateQuerier }))
mock.module('./action-executor', () => ({ ...realActionExecutor }))
mock.module('../../shared/tmux', () => ({ ...realSharedTmux }))
})
const trackedSessions = new Set<string>()
function createMockContext(overrides?: {

View File

@@ -1,13 +1,21 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config"
const attemptDeduplicationRecoveryMock = mock(async () => {})
const realDeduplicationRecovery = await import("./deduplication-recovery")
const attemptDeduplicationRecoveryMock = mock<(sessionID: string) => Promise<void>>(
async () => {}
)
mock.module("./deduplication-recovery", () => ({
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
}))
afterAll(() => {
mock.module("./deduplication-recovery", () => ({ ...realDeduplicationRecovery }))
})
function createImmediateTimeouts(): () => void {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
@@ -37,13 +45,15 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
const experimental = {
dynamic_context_pruning: {
enabled: true,
notification: "off",
protected_tools: [],
strategies: {
deduplication: { enabled: true },
},
},
} satisfies ExperimentalConfig
let resolveSummarize: (() => void) | null = null
let resolveSummarize: ((value?: void) => void) | null = null
const summarizePromise = new Promise<void>((resolve) => {
resolveSummarize = resolve
})
@@ -62,7 +72,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
try {
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const ctx = { client: mockClient, directory: "/tmp" } as unknown as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental })
// first error triggers compaction (setTimeout runs immediately due to mock)
@@ -105,7 +115,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
}
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const ctx = { client: mockClient, directory: "/tmp" } as unknown as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx)
//#when - single error (no compaction in progress)

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import { truncateUntilTargetTokens } from "./storage"
import * as storage from "./storage"
@@ -11,6 +11,10 @@ mock.module("./storage", () => {
}
})
afterAll(() => {
mock.module("./storage", () => ({ ...storage }))
})
describe("truncateUntilTargetTokens", () => {
const sessionID = "test-session"

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, afterAll, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -9,20 +9,19 @@ import {
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
const realClaudeCodeSessionState = await import(
"../../features/claude-code-session-state"
)
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
afterAll(() => {
mock.module("../../features/claude-code-session-state", () => ({
...realClaudeCodeSessionState,
}))
})
describe("atlas hook", () => {
let TEST_DIR: string
let SISYPHUS_DIR: string
@@ -77,7 +76,6 @@ describe("atlas hook", () => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("tool.execute.after handler", () => {

View File

@@ -1,4 +1,11 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"
const realChecker = await import("../checker")
const realVersionChannel = await import("../version-channel")
const realCache = await import("../cache")
const realConfigManager = await import("../../../cli/config-manager")
const realUpdateToasts = await import("./update-toasts")
const realLogger = await import("../../../shared/logger")
// Mock modules before importing
const mockFindPluginEntry = mock(() => null as any)
@@ -39,6 +46,15 @@ mock.module("../../../shared/logger", () => ({
log: () => {},
}))
afterAll(() => {
mock.module("../checker", () => ({ ...realChecker }))
mock.module("../version-channel", () => ({ ...realVersionChannel }))
mock.module("../cache", () => ({ ...realCache }))
mock.module("../../../cli/config-manager", () => ({ ...realConfigManager }))
mock.module("./update-toasts", () => ({ ...realUpdateToasts }))
mock.module("../../../shared/logger", () => ({ ...realLogger }))
})
const { runBackgroundUpdateCheck } = await import("./background-update-check")
describe("runBackgroundUpdateCheck", () => {

View File

@@ -1,7 +1,10 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"
import type { ClaudeHooksConfig } from "./types"
import type { StopContext } from "./stop"
const realCommandExecutor = await import("../../shared/command-executor")
const realLogger = await import("../../shared/logger")
const mockExecuteHookCommand = mock(() =>
Promise.resolve({ exitCode: 0, stdout: "", stderr: "" })
)
@@ -17,6 +20,11 @@ mock.module("../../shared/logger", () => ({
getLogFilePath: () => "/tmp/test.log",
}))
afterAll(() => {
mock.module("../../shared/command-executor", () => ({ ...realCommandExecutor }))
mock.module("../../shared/logger", () => ({ ...realLogger }))
})
const { executeStopHooks } = await import("./stop")
function createStopContext(overrides?: Partial<StopContext>): StopContext {

View File

@@ -1,10 +1,19 @@
import { describe, test, expect, mock } from "bun:test"
import { describe, test, expect, mock, afterAll } from "bun:test"
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import type { PendingCall } from "./types"
const realCli = await import("./cli")
const cliTsHref = new URL("./cli.ts", import.meta.url).href
afterAll(() => {
mock.module("./cli", () => ({ ...realCli }))
mock.module("./cli.ts", () => ({ ...realCli }))
mock.module(cliTsHref, () => ({ ...realCli }))
})
function createMockInput() {
return {
session_id: "test",

View File

@@ -1,4 +1,6 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"
const realCliRunner = await import("./cli-runner")
const processApplyPatchEditsWithCli = mock(async () => {})
@@ -10,6 +12,10 @@ mock.module("./cli-runner", () => ({
processApplyPatchEditsWithCli,
}))
afterAll(() => {
mock.module("./cli-runner", () => ({ ...realCliRunner }))
})
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker apply_patch integration", () => {

View File

@@ -1,4 +1,6 @@
import { describe, expect, it, mock } from "bun:test"
import { describe, expect, it, mock, afterAll } from "bun:test"
const realSystemDirective = await import("../../shared/system-directive")
mock.module("../../shared/system-directive", () => ({
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
@@ -14,6 +16,10 @@ mock.module("../../shared/system-directive", () => ({
},
}))
afterAll(() => {
mock.module("../../shared/system-directive", () => ({ ...realSystemDirective }))
})
import { createCompactionContextInjector } from "./index"
import { TaskHistory } from "../../features/background-agent/task-history"

View File

@@ -30,7 +30,7 @@ function createMockContext(todoResponses: TodoSnapshot[][]): PluginInput {
},
},
directory: "/tmp/test",
} as PluginInput
} as unknown as PluginInput
}
describe("compaction-todo-preserver", () => {
@@ -38,7 +38,7 @@ describe("compaction-todo-preserver", () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-missing"
const todos = [
const todos: TodoSnapshot[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
]
@@ -58,7 +58,7 @@ describe("compaction-todo-preserver", () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-present"
const todos = [
const todos: TodoSnapshot[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
]
const ctx = createMockContext([todos, todos])

View File

@@ -1,34 +1,59 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { beforeEach, afterEach, describe, expect, it, mock, afterAll } from "bun:test"
const readFileSyncMock = mock((_: string, __: string) => "# AGENTS")
const realNodeFs = await import("node:fs")
const realFinder = await import("./finder")
const realStorage = await import("./storage")
const originalReadFileSync = realNodeFs.readFileSync
const readFileSyncMock = mock((filePath: string, encoding?: string) => {
if (String(filePath).endsWith("AGENTS.md")) {
return "# AGENTS"
}
return originalReadFileSync(filePath as never, encoding as never)
})
const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
mock.module("node:fs", () => ({
readFileSync: readFileSyncMock,
}))
afterAll(() => {
mock.module("node:fs", () => ({ ...realNodeFs }))
mock.module("./finder", () => ({ ...realFinder }))
mock.module("./storage", () => ({ ...realStorage }))
})
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
let processFilePathForAgentsInjection: typeof import("./injector").processFilePathForAgentsInjection
describe("processFilePathForAgentsInjection", () => {
beforeEach(() => {
beforeEach(async () => {
readFileSyncMock.mockClear()
findAgentsMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
mock.module("node:fs", () => ({
...realNodeFs,
readFileSync: readFileSyncMock,
}))
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
;({ processFilePathForAgentsInjection } = await import(`./injector?${Date.now()}`))
})
afterEach(() => {
mock.module("node:fs", () => ({ ...realNodeFs }))
mock.module("./finder", () => ({ ...realFinder }))
mock.module("./storage", () => ({ ...realStorage }))
})
it("does not save when all discovered paths are already cached", async () => {

View File

@@ -1,34 +1,59 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { beforeEach, afterEach, describe, expect, it, mock, afterAll } from "bun:test"
const readFileSyncMock = mock((_: string, __: string) => "# README")
const realNodeFs = await import("node:fs")
const realFinder = await import("./finder")
const realStorage = await import("./storage")
const originalReadFileSync = realNodeFs.readFileSync
const readFileSyncMock = mock((filePath: string, encoding?: string) => {
if (String(filePath).endsWith("README.md")) {
return "# README"
}
return originalReadFileSync(filePath as never, encoding as never)
})
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
mock.module("node:fs", () => ({
readFileSync: readFileSyncMock,
}))
afterAll(() => {
mock.module("node:fs", () => ({ ...realNodeFs }))
mock.module("./finder", () => ({ ...realFinder }))
mock.module("./storage", () => ({ ...realStorage }))
})
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
let processFilePathForReadmeInjection: typeof import("./injector").processFilePathForReadmeInjection
describe("processFilePathForReadmeInjection", () => {
beforeEach(() => {
beforeEach(async () => {
readFileSyncMock.mockClear()
findReadmeMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
mock.module("node:fs", () => ({
...realNodeFs,
readFileSync: readFileSyncMock,
}))
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
;({ processFilePathForReadmeInjection } = await import(`./injector?${Date.now()}`))
})
afterEach(() => {
mock.module("node:fs", () => ({ ...realNodeFs }))
mock.module("./finder", () => ({ ...realFinder }))
mock.module("./storage", () => ({ ...realStorage }))
})
it("does not save when all discovered paths are already cached", async () => {

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -6,16 +6,6 @@ import { randomUUID } from "node:crypto"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { clearSessionAgent } from "../../features/claude-code-session-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `prometheus-md-only-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
const { createPrometheusMdOnlyHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
@@ -52,7 +42,6 @@ describe("prometheus-md-only", () => {
// ignore
}
}
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("agent name matching", () => {

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, mock, afterAll } from "bun:test";
import * as fs from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
@@ -6,6 +6,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants";
const realNodeFs = await import("node:fs");
const realNodeOs = await import("node:os");
const realMatcher = await import("./matcher");
type StatSnapshot = { mtimeMs: number; size: number };
let trackedRulePath = "";
@@ -56,6 +60,12 @@ mock.module("./matcher", () => ({
isDuplicateByContentHash: (hash: string, cache: Set<string>) => cache.has(hash),
}));
afterAll(() => {
mock.module("node:fs", () => ({ ...realNodeFs }));
mock.module("node:os", () => ({ ...realNodeOs }));
mock.module("./matcher", () => ({ ...realMatcher }));
});
function createOutput(): { title: string; output: string; metadata: unknown } {
return { title: "tool", output: "", metadata: {} };
}

View File

@@ -518,7 +518,7 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
})
}, 20000)
test("should keep injecting even when todos remain unchanged across cycles", async () => {
//#given
@@ -553,7 +553,7 @@ describe("todo-continuation-enforcer", () => {
//#then — all 5 injections should fire (no stagnation cap)
expect(promptCalls).toHaveLength(5)
})
}, 30000)
test("should skip idle handling while injection is in flight", async () => {
//#given
@@ -613,7 +613,7 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
})
}, 20000)
test("should accept skipAgents option without error", async () => {
// given - session with skipAgents configured for Prometheus

View File

@@ -0,0 +1,14 @@
import { describe, test, expect } from "bun:test"
import { getAgentsConfigDir } from "./agents-config-dir"
describe("getAgentsConfigDir", () => {
test("returns path ending with .agents", () => {
// given agents config dir is requested
// when getAgentsConfigDir is called
const result = getAgentsConfigDir()
// then returns path ending with .agents
expect(result.endsWith(".agents")).toBe(true)
})
})

View File

@@ -0,0 +1,6 @@
import { homedir } from "node:os"
import { join } from "node:path"
export function getAgentsConfigDir(): string {
return join(homedir(), ".agents")
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
import { mkdirSync, writeFileSync, symlinkSync, rmSync, realpathSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { resolveSymlink, resolveSymlinkAsync, isSymbolicLink } from "./file-utils"
const testDir = join(realpathSync(tmpdir()), "file-utils-test-" + Date.now())
// Create a directory structure that mimics the real-world scenario:
//
// testDir/
// ├── repo/
// │ ├── skills/
// │ │ └── category/
// │ │ └── my-skill/
// │ │ └── SKILL.md
// │ └── .opencode/
// │ └── skills/
// │ └── my-skill -> ../../skills/category/my-skill (relative symlink)
// └── config/
// └── skills -> ../repo/.opencode/skills (absolute symlink)
const realSkillDir = join(testDir, "repo", "skills", "category", "my-skill")
const repoOpencodeSkills = join(testDir, "repo", ".opencode", "skills")
const configSkills = join(testDir, "config", "skills")
beforeAll(() => {
// Create real skill directory with a file
mkdirSync(realSkillDir, { recursive: true })
writeFileSync(join(realSkillDir, "SKILL.md"), "# My Skill")
// Create .opencode/skills/ with a relative symlink to the real skill
mkdirSync(repoOpencodeSkills, { recursive: true })
symlinkSync("../../skills/category/my-skill", join(repoOpencodeSkills, "my-skill"))
// Create config/skills as an absolute symlink to .opencode/skills
mkdirSync(join(testDir, "config"), { recursive: true })
symlinkSync(repoOpencodeSkills, configSkills)
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
})
describe("resolveSymlink", () => {
it("resolves a regular file path to itself", () => {
const filePath = join(realSkillDir, "SKILL.md")
expect(resolveSymlink(filePath)).toBe(filePath)
})
it("resolves a relative symlink to its real path", () => {
const symlinkPath = join(repoOpencodeSkills, "my-skill")
expect(resolveSymlink(symlinkPath)).toBe(realSkillDir)
})
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", () => {
// This is the real-world scenario:
// config/skills/my-skill -> (follows config/skills) -> repo/.opencode/skills/my-skill -> repo/skills/category/my-skill
const chainedPath = join(configSkills, "my-skill")
expect(resolveSymlink(chainedPath)).toBe(realSkillDir)
})
it("returns the original path for non-existent paths", () => {
const fakePath = join(testDir, "does-not-exist")
expect(resolveSymlink(fakePath)).toBe(fakePath)
})
})
describe("resolveSymlinkAsync", () => {
it("resolves a regular file path to itself", async () => {
const filePath = join(realSkillDir, "SKILL.md")
expect(await resolveSymlinkAsync(filePath)).toBe(filePath)
})
it("resolves a relative symlink to its real path", async () => {
const symlinkPath = join(repoOpencodeSkills, "my-skill")
expect(await resolveSymlinkAsync(symlinkPath)).toBe(realSkillDir)
})
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", async () => {
const chainedPath = join(configSkills, "my-skill")
expect(await resolveSymlinkAsync(chainedPath)).toBe(realSkillDir)
})
it("returns the original path for non-existent paths", async () => {
const fakePath = join(testDir, "does-not-exist")
expect(await resolveSymlinkAsync(fakePath)).toBe(fakePath)
})
})
describe("isSymbolicLink", () => {
it("returns true for a symlink", () => {
expect(isSymbolicLink(join(repoOpencodeSkills, "my-skill"))).toBe(true)
})
it("returns false for a regular directory", () => {
expect(isSymbolicLink(realSkillDir)).toBe(false)
})
it("returns false for a non-existent path", () => {
expect(isSymbolicLink(join(testDir, "does-not-exist"))).toBe(false)
})
})

View File

@@ -1,6 +1,5 @@
import { lstatSync, readlinkSync } from "fs"
import { lstatSync, realpathSync } from "fs"
import { promises as fs } from "fs"
import { resolve } from "path"
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
@@ -16,11 +15,7 @@ export function isSymbolicLink(filePath: string): boolean {
export function resolveSymlink(filePath: string): string {
try {
const stats = lstatSync(filePath, { throwIfNoEntry: false })
if (stats?.isSymbolicLink()) {
return resolve(filePath, "..", readlinkSync(filePath))
}
return filePath
return realpathSync(filePath)
} catch {
return filePath
}
@@ -28,12 +23,7 @@ export function resolveSymlink(filePath: string): string {
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
const stats = await fs.lstat(filePath)
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(filePath)
return resolve(filePath, "..", linkTarget)
}
return filePath
return await fs.realpath(filePath)
} catch {
return filePath
}

View File

@@ -0,0 +1,72 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { setSessionTools, getSessionTools, clearSessionTools } from "./session-tools-store"
describe("session-tools-store", () => {
beforeEach(() => {
clearSessionTools()
})
test("returns undefined for unknown session", () => {
//#given
const sessionID = "ses_unknown"
//#when
const result = getSessionTools(sessionID)
//#then
expect(result).toBeUndefined()
})
test("stores and retrieves tools for a session", () => {
//#given
const sessionID = "ses_abc123"
const tools = { question: false, task: true, call_omo_agent: true }
//#when
setSessionTools(sessionID, tools)
const result = getSessionTools(sessionID)
//#then
expect(result).toEqual({ question: false, task: true, call_omo_agent: true })
})
test("overwrites existing tools for same session", () => {
//#given
const sessionID = "ses_abc123"
setSessionTools(sessionID, { question: false })
//#when
setSessionTools(sessionID, { question: true, task: false })
const result = getSessionTools(sessionID)
//#then
expect(result).toEqual({ question: true, task: false })
})
test("clearSessionTools removes all entries", () => {
//#given
setSessionTools("ses_1", { question: false })
setSessionTools("ses_2", { task: true })
//#when
clearSessionTools()
//#then
expect(getSessionTools("ses_1")).toBeUndefined()
expect(getSessionTools("ses_2")).toBeUndefined()
})
test("returns a copy, not a reference", () => {
//#given
const sessionID = "ses_abc123"
const tools = { question: false }
setSessionTools(sessionID, tools)
//#when
const result = getSessionTools(sessionID)!
result.question = true
//#then
expect(getSessionTools(sessionID)).toEqual({ question: false })
})
})

View File

@@ -0,0 +1,14 @@
const store = new Map<string, Record<string, boolean>>()
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
store.set(sessionID, { ...tools })
}
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
const tools = store.get(sessionID)
return tools ? { ...tools } : undefined
}
export function clearSessionTools(): void {
store.clear()
}

View File

@@ -5,6 +5,7 @@ import { log } from "../../shared"
import type { CallOmoAgentArgs } from "./types"
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
import { getMessageDir } from "./message-storage-directory"
import { getSessionTools } from "../../shared/session-tools-store"
export async function executeBackgroundAgent(
args: CallOmoAgentArgs,
@@ -36,6 +37,7 @@ export async function executeBackgroundAgent(
parentSessionID: toolContext.sessionID,
parentMessageID: toolContext.messageID,
parentAgent,
parentTools: getSessionTools(toolContext.sessionID),
})
const waitStart = Date.now()

View File

@@ -5,6 +5,7 @@ import { consumeNewMessages } from "../../shared/session-cursor"
import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getMessageDir } from "./message-dir"
import { getSessionTools } from "../../shared/session-tools-store"
export async function executeBackground(
args: CallOmoAgentArgs,
@@ -41,6 +42,7 @@ export async function executeBackground(
parentSessionID: toolContext.sessionID,
parentMessageID: toolContext.messageID,
parentAgent,
parentTools: getSessionTools(toolContext.sessionID),
})
const WAIT_FOR_SESSION_INTERVAL_MS = 50

View File

@@ -1,16 +1,28 @@
const { describe, test, expect, mock } = require("bun:test")
const { describe, test, expect, mock, beforeEach, afterEach } = require("bun:test")
mock.module("./session-creator", () => ({
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123" })),
}))
const realCompletionPoller = require("./completion-poller")
const realMessageProcessor = require("./message-processor")
mock.module("./completion-poller", () => ({
waitForCompletion: mock(async () => {}),
}))
const waitForCompletionMock = mock(async () => {})
const processMessagesMock = mock(async () => "agent response")
mock.module("./message-processor", () => ({
processMessages: mock(async () => "agent response"),
}))
beforeEach(() => {
waitForCompletionMock.mockClear()
processMessagesMock.mockClear()
mock.module("./completion-poller", () => ({
waitForCompletion: waitForCompletionMock,
}))
mock.module("./message-processor", () => ({
processMessages: processMessagesMock,
}))
})
afterEach(() => {
mock.module("./completion-poller", () => ({ ...realCompletionPoller }))
mock.module("./message-processor", () => ({ ...realMessageProcessor }))
})
describe("executeSync", () => {
test("passes question=false via tools parameter to block question tool", async () => {
@@ -27,6 +39,7 @@ describe("executeSync", () => {
subagent_type: "explore",
description: "test task",
prompt: "find something",
session_id: "ses-test-123",
}
const toolContext = {
@@ -39,7 +52,10 @@ describe("executeSync", () => {
const ctx = {
client: {
session: { promptAsync },
session: {
promptAsync,
get: mock(async () => ({ data: { id: "ses-test-123" } })),
},
},
}
@@ -65,6 +81,7 @@ describe("executeSync", () => {
subagent_type: "librarian",
description: "search docs",
prompt: "find docs",
session_id: "ses-test-123",
}
const toolContext = {
@@ -77,7 +94,10 @@ describe("executeSync", () => {
const ctx = {
client: {
session: { promptAsync },
session: {
promptAsync,
get: mock(async () => ({ data: { id: "ses-test-123" } })),
},
},
}

View File

@@ -2,6 +2,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext } from "./executor-types"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
export async function executeBackgroundContinuation(
args: DelegateTaskArgs,
@@ -19,6 +20,7 @@ export async function executeBackgroundContinuation(
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
parentTools: getSessionTools(parentContext.sessionID),
})
const bgContMeta = {

View File

@@ -3,6 +3,7 @@ import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
export async function executeBackgroundTask(
args: DelegateTaskArgs,
@@ -24,6 +25,7 @@ export async function executeBackgroundTask(
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
parentTools: getSessionTools(parentContext.sessionID),
model: categoryModel,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,

View File

@@ -9,6 +9,7 @@ import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-re
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter"
import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps"
import { setSessionTools } from "../../shared/session-tools-store"
export async function executeSyncContinuation(
args: DelegateTaskArgs,
@@ -77,6 +78,13 @@ export async function executeSyncContinuation(
}
const allowTask = isPlanFamily(resumeAgent)
const tools = {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: allowTask,
call_omo_agent: true,
question: false,
}
setSessionTools(args.session_id!, tools)
await promptWithModelSuggestionRetry(client, {
path: { id: args.session_id! },
@@ -84,12 +92,7 @@ export async function executeSyncContinuation(
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
...(resumeVariant !== undefined ? { variant: resumeVariant } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: allowTask,
call_omo_agent: true, // Intentionally overrides restrictions - continuation context needs delegation capability even for restricted agents
question: false,
},
tools,
parts: [{ type: "text", text: args.prompt }],
},
})

View File

@@ -3,6 +3,7 @@ import { isPlanFamily } from "./constants"
import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { formatDetailedError } from "./error-formatting"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { setSessionTools } from "../../shared/session-tools-store"
export async function sendSyncPrompt(
client: OpencodeClient,
@@ -18,17 +19,19 @@ export async function sendSyncPrompt(
): Promise<string | null> {
try {
const allowTask = isPlanFamily(input.agentToUse)
const tools = {
task: allowTask,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agentToUse),
}
setSessionTools(input.sessionID, tools)
await promptWithModelSuggestionRetry(client, {
path: { id: input.sessionID },
body: {
agent: input.agentToUse,
system: input.systemContent,
tools: {
task: allowTask,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agentToUse),
},
tools,
parts: [{ type: "text", text: input.args.prompt }],
...(input.categoryModel ? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } } : {}),
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),

View File

@@ -4,6 +4,7 @@ import { getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
export async function executeUnstableAgentTask(
args: DelegateTaskArgs,
@@ -26,6 +27,7 @@ export async function executeUnstableAgentTask(
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
parentTools: getSessionTools(parentContext.sessionID),
model: categoryModel,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,

View File

@@ -2,7 +2,9 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach, afterAll } from "bun:test"
const realJsonRpcNode = await import("vscode-jsonrpc/node")
mock.module("vscode-jsonrpc/node", () => ({
createMessageConnection: () => {
@@ -12,6 +14,10 @@ mock.module("vscode-jsonrpc/node", () => ({
StreamMessageWriter: function StreamMessageWriter() {},
}))
afterAll(() => {
mock.module("vscode-jsonrpc/node", () => ({ ...realJsonRpcNode }))
})
import { LSPClient, lspManager, validateCwd } from "./client"
import type { ResolvedServer } from "./types"

View File

@@ -1,9 +1,11 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
const realConstants = await import("./constants")
const TEST_DIR = join(tmpdir(), `omo-test-session-manager-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
const TEST_PART_STORAGE = join(TEST_DIR, "part")
@@ -26,6 +28,10 @@ mock.module("./constants", () => ({
TOOL_NAME_PREFIX: "session_",
}))
afterAll(() => {
mock.module("./constants", () => ({ ...realConstants }))
})
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
await import("./storage")

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import { describe, it, expect, beforeEach, mock, spyOn, afterAll } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import * as fs from "node:fs"
import { createSkillTool } from "./tools"
@@ -8,6 +8,8 @@ import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
const originalReadFileSync = fs.readFileSync.bind(fs)
const realNodeFs = await import("node:fs")
mock.module("node:fs", () => ({
...fs,
readFileSync: (path: string, encoding?: string) => {
@@ -21,6 +23,10 @@ Test skill body content`
},
}))
afterAll(() => {
mock.module("node:fs", () => ({ ...realNodeFs }))
})
function createMockSkill(name: string, options: { agent?: string } = {}): LoadedSkill {
return {
name,