fix: resolve 25 pre-publish blockers

- postinstall.mjs: fix alias package detection
- migrate-legacy-plugin-entry: dedupe + regression tests
- task_system: default consistency across runtime paths
- task() contract: consistent tool behavior
- runtime model selection, tool cap, stale-task cancellation
- recovery sanitization, context-limit gating
- Ralph semantic DONE hardening, Atlas fallback persistence
- native-skill description/content, skill path traversal guard
- publish workflow: platform awaited via reusable workflow job
- release: version edits reapplied before commit/tag
- JSONC plugin migration: top-level plugin key safety
- cold-cache: user fallback models skip disconnected providers
- docs/version/release framing updates

Verified: bun test (4599 pass), tsc --noEmit clean, bun run build clean
This commit is contained in:
YeonGyu-Kim
2026-03-28 15:24:18 +09:00
parent 44b039bef6
commit d2c576c510
62 changed files with 1264 additions and 292 deletions
@@ -3312,6 +3312,9 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
get: async () => {
throw new Error("missing")
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
@@ -3348,6 +3351,9 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
get: async () => {
throw new Error("missing")
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
@@ -3437,6 +3443,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
consecutiveMissedPolls: 2,
}
getTaskMap(manager).set(task.id, task)
@@ -3471,6 +3478,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
consecutiveMissedPolls: 2,
}
getTaskMap(manager).set(task.id, task)
@@ -8,6 +8,7 @@ describe("checkAndInterruptStaleTasks", () => {
const mockClient = {
session: {
abort: mock(() => Promise.resolve()),
get: mock(() => Promise.resolve({ data: { id: "ses-1" } })),
},
}
const mockConcurrencyManager = {
@@ -35,6 +36,11 @@ describe("checkAndInterruptStaleTasks", () => {
beforeEach(() => {
fixedTime = Date.now()
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
mockClient.session.abort.mockClear()
mockClient.session.get.mockReset()
mockClient.session.get.mockResolvedValue({ data: { id: "ses-1" } })
mockConcurrencyManager.release.mockClear()
mockNotify.mockClear()
})
afterEach(() => {
@@ -288,6 +294,59 @@ describe("checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("running")
})
it("should NOT cancel healthy task on first missing status poll", async () => {
//#given — one missing poll should not be enough to declare the session gone
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
//#then
expect(task.status).toBe("running")
expect(task.consecutiveMissedPolls).toBe(1)
expect(mockClient.session.get).not.toHaveBeenCalled()
})
it("should NOT cancel task when session.get confirms the session still exists", async () => {
//#given — repeated missing polls but direct lookup still succeeds
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
consecutiveMissedPolls: 2,
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
//#then
expect(task.status).toBe("running")
expect(task.consecutiveMissedPolls).toBe(0)
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: "ses-1" } })
})
it("should use session-gone timeout when session is missing from status map (with progress)", async () => {
//#given — lastUpdate 2min ago, session completely gone from status
const task = createRunningTask({
@@ -296,8 +355,11 @@ describe("checkAndInterruptStaleTasks", () => {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
consecutiveMissedPolls: 2,
})
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({
tasks: [task],
@@ -318,8 +380,11 @@ describe("checkAndInterruptStaleTasks", () => {
const task = createRunningTask({
startedAt: new Date(Date.now() - 120_000),
progress: undefined,
consecutiveMissedPolls: 2,
})
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — session gone, sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({
tasks: [task],
@@ -343,8 +408,11 @@ describe("checkAndInterruptStaleTasks", () => {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
consecutiveMissedPolls: 2,
})
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — session is idle (present in map), staleTimeoutMs = 180s
await checkAndInterruptStaleTasks({
tasks: [task],
@@ -367,8 +435,11 @@ describe("checkAndInterruptStaleTasks", () => {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
consecutiveMissedPolls: 2,
})
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — no config (default sessionGoneTimeoutMs = 60_000)
await checkAndInterruptStaleTasks({
tasks: [task],
+36 -6
View File
@@ -16,6 +16,8 @@ import {
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import { isActiveSessionStatus } from "./session-status-classifier"
const MIN_SESSION_GONE_POLLS = 3
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",
@@ -97,6 +99,15 @@ export function pruneStaleTasksAndNotifications(args: {
export type SessionStatusMap = Record<string, { type: string }>
async function verifySessionExists(client: OpencodeClient, sessionID: string): Promise<boolean> {
try {
const result = await client.session.get({ path: { id: sessionID } })
return !!result.data
} catch {
return false
}
}
export async function checkAndInterruptStaleTasks(args: {
tasks: Iterable<BackgroundTask>
client: OpencodeClient
@@ -130,14 +141,28 @@ export async function checkAndInterruptStaleTasks(args: {
const sessionStatus = sessionStatuses?.[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && isActiveSessionStatus(sessionStatus)
const sessionGone = sessionStatuses !== undefined && sessionStatus === undefined
const sessionMissing = sessionStatuses !== undefined && sessionStatus === undefined
const runtime = now - startedAt.getTime()
if (sessionMissing) {
task.consecutiveMissedPolls = (task.consecutiveMissedPolls ?? 0) + 1
} else if (sessionStatuses !== undefined) {
task.consecutiveMissedPolls = 0
}
const sessionGone = sessionMissing && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue
if (sessionMissing && !sessionGone) continue
const effectiveTimeout = sessionGone ? sessionGoneTimeoutMs : messageStalenessMs
if (runtime <= effectiveTimeout) continue
if (sessionGone && await verifySessionExists(client, sessionID)) {
task.consecutiveMissedPolls = 0
continue
}
const staleMinutes = Math.round(runtime / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled"
@@ -171,11 +196,16 @@ export async function checkAndInterruptStaleTasks(args: {
if (timeSinceLastUpdate <= effectiveStaleTimeout) continue
if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled"
task.error = `Stale timeout (${reason} for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/oh-my-opencode.json.`
task.completedAt = new Date()
if (sessionGone && await verifySessionExists(client, sessionID)) {
task.consecutiveMissedPolls = 0
continue
}
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled"
task.error = `Stale timeout (${reason} for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/oh-my-opencode.json.`
task.completedAt = new Date()
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
+2
View File
@@ -66,6 +66,8 @@ export interface BackgroundTask {
lastMsgCount?: number
/** Number of consecutive polls with stable message count */
stablePolls?: number
/** Number of consecutive polls where session was missing from status map */
consecutiveMissedPolls?: number
}
export interface LaunchInput {
@@ -3,9 +3,13 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import type { LoadedSkill } from "./types"
export function extractSkillTemplate(skill: LoadedSkill): string {
if (skill.path) {
const content = readFileSync(skill.path, "utf-8")
const { body } = parseFrontmatter(content)
if (skill.scope === "config" && skill.definition.template) {
return skill.definition.template
}
if (skill.path) {
const content = readFileSync(skill.path, "utf-8")
const { body } = parseFrontmatter(content)
return body.trim()
}
return skill.definition.template || ""
@@ -0,0 +1,105 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
const replaceEmptyTextPartsAsync = mock(() => Promise.resolve(false))
const injectTextPartAsync = mock(() => Promise.resolve(false))
const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[]))
mock.module("../../shared", () => ({
normalizeSDKResponse: (response: { data?: unknown[] }) => response.data ?? [],
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => true,
}))
mock.module("../session-recovery/storage", () => ({
findEmptyMessages: () => [],
findMessagesWithEmptyTextParts: () => [],
injectTextPart: () => false,
replaceEmptyTextParts: () => false,
}))
mock.module("../session-recovery/storage/empty-text", () => ({
replaceEmptyTextPartsAsync,
findMessagesWithEmptyTextPartsFromSDK,
}))
mock.module("../session-recovery/storage/text-part-injector", () => ({
injectTextPartAsync,
}))
async function importFreshMessageBuilder(): Promise<typeof import("./message-builder")> {
return import(`./message-builder?test=${Date.now()}-${Math.random()}`)
}
afterAll(() => {
mock.restore()
})
describe("sanitizeEmptyMessagesBeforeSummarize", () => {
beforeEach(() => {
replaceEmptyTextPartsAsync.mockReset()
replaceEmptyTextPartsAsync.mockResolvedValue(false)
injectTextPartAsync.mockReset()
injectTextPartAsync.mockResolvedValue(false)
findMessagesWithEmptyTextPartsFromSDK.mockReset()
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue([])
})
test("#given sqlite message with tool content and empty text part #when sanitizing #then it fixes the mixed-content message", async () => {
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
const client = {
session: {
messages: mock(() => Promise.resolve({
data: [
{
info: { id: "msg-1" },
parts: [
{ type: "tool_result", text: "done" },
{ type: "text", text: "" },
],
},
],
})),
},
} as never
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue(["msg-1"])
replaceEmptyTextPartsAsync.mockResolvedValue(true)
const fixedCount = await sanitizeEmptyMessagesBeforeSummarize("ses-1", client)
expect(fixedCount).toBe(1)
expect(replaceEmptyTextPartsAsync).toHaveBeenCalledWith(client, "ses-1", "msg-1", PLACEHOLDER_TEXT)
expect(injectTextPartAsync).not.toHaveBeenCalled()
})
test("#given sqlite message with mixed content and failed replacement #when sanitizing #then it injects the placeholder text part", async () => {
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
const client = {
session: {
messages: mock(() => Promise.resolve({
data: [
{
info: { id: "msg-2" },
parts: [
{ type: "tool_use", text: "call" },
{ type: "text", text: "" },
],
},
],
})),
},
} as never
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue(["msg-2"])
injectTextPartAsync.mockResolvedValue(true)
const fixedCount = await sanitizeEmptyMessagesBeforeSummarize("ses-2", client)
expect(fixedCount).toBe(1)
expect(injectTextPartAsync).toHaveBeenCalledWith(client, "ses-2", "msg-2", PLACEHOLDER_TEXT)
})
})
@@ -8,7 +8,7 @@ import {
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { findMessagesWithEmptyTextPartsFromSDK, replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client"
@@ -86,12 +86,14 @@ export async function sanitizeEmptyMessagesBeforeSummarize(
): Promise<number> {
if (client && isSqliteBackend()) {
const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
if (emptyMessageIds.length === 0) {
const emptyTextPartIds = await findMessagesWithEmptyTextPartsFromSDK(client, sessionID)
const allIds = [...new Set([...emptyMessageIds, ...emptyTextPartIds])]
if (allIds.length === 0) {
return 0
}
let fixedCount = 0
for (const messageID of emptyMessageIds) {
for (const messageID of allIds) {
const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
if (replaced) {
fixedCount++
@@ -107,7 +109,7 @@ export async function sanitizeEmptyMessagesBeforeSummarize(
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID,
fixedCount,
totalEmpty: emptyMessageIds.length,
totalEmpty: allIds.length,
})
}
@@ -1,8 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { isAgentRegistered } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { getAgentDisplayName } from "../../shared/agent-display-names"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
@@ -48,24 +49,33 @@ export async function injectBoulderContinuation(input: {
const preferredSessionContext = preferredTaskSessionId
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
: ""
const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
sessionID,
agent: continuationAgent ?? agent ?? "unknown",
})
return
}
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: getAgentDisplayName(agent ?? "atlas"),
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: getAgentConfigKey(continuationAgent),
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)],
},
query: { directory: ctx.directory },
@@ -6,7 +6,7 @@ import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting } from "../../features/claude-code-session-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import type { BoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-compaction-storage-${randomUUID()}`)
@@ -66,6 +66,8 @@ describe("atlas hook compaction agent filtering", () => {
mkdirSync(testDirectory, { recursive: true })
clearBoulderState(testDirectory)
_resetForTesting()
registerAgentName("atlas")
registerAgentName("sisyphus")
})
afterEach(() => {
+3 -1
View File
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
const { createAtlasHook } = await import("./index")
@@ -64,6 +64,8 @@ describe("atlas hook idle-event session lineage", () => {
promptCalls = []
clearBoulderState(testDirectory)
_resetForTesting()
registerAgentName("atlas")
registerAgentName("sisyphus")
subagentSessions.clear()
})
+14 -6
View File
@@ -5,7 +5,7 @@ import {
readBoulderState,
readCurrentTopLevelTask,
} from "../../features/boulder-state"
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { getSessionAgent, isAgentRegistered, subagentSessions } from "../../features/claude-code-session-state"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
@@ -141,7 +141,15 @@ export async function handleAtlasSessionIdle(input: {
if (subagentSessions.has(sessionID)) {
const sessionAgent = getSessionAgent(sessionID)
const agentKey = getAgentConfigKey(sessionAgent ?? "")
const requiredAgentKey = getAgentConfigKey(boulderState.agent ?? "atlas")
const requiredAgentName = boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
if (!requiredAgentName || !isAgentRegistered(requiredAgentName)) {
log(`[${HOOK_NAME}] Skipped: boulder agent is unavailable for continuation`, {
sessionID,
requiredAgent: boulderState.agent ?? "unknown",
})
return
}
const requiredAgentKey = getAgentConfigKey(requiredAgentName)
const agentMatches =
agentKey === requiredAgentKey ||
(requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus"))
@@ -149,10 +157,10 @@ export async function handleAtlasSessionIdle(input: {
log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, {
sessionID,
agent: sessionAgent ?? "unknown",
requiredAgent: boulderState.agent ?? "atlas",
})
return
}
requiredAgent: requiredAgentName,
})
return
}
}
const sessionState = getState(sessionID)
+10 -4
View File
@@ -9,7 +9,7 @@ import {
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import type { PendingTaskRef } from "./types"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
@@ -90,6 +90,9 @@ describe("atlas hook", () => {
}
beforeEach(() => {
_resetForTesting()
registerAgentName("atlas")
registerAgentName("sisyphus")
TEST_DIR = join(tmpdir(), `atlas-test-${randomUUID()}`)
SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
if (!existsSync(TEST_DIR)) {
@@ -102,6 +105,7 @@ describe("atlas hook", () => {
})
afterEach(() => {
_resetForTesting()
clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
@@ -1182,9 +1186,11 @@ session_id: ses_untrusted_999
beforeEach(() => {
_resetForTesting()
subagentSessions.clear()
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
registerAgentName("atlas")
registerAgentName("sisyphus")
subagentSessions.clear()
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
afterEach(() => {
cleanupMessageStorage(MAIN_SESSION_ID)
@@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { autoMigrateLegacyPluginEntry } from "./auto-migrate"
async function importFreshAutoMigrateModule(): Promise<typeof import("./auto-migrate")> {
return import(`./auto-migrate?test=${Date.now()}-${Math.random()}`)
}
describe("autoMigrateLegacyPluginEntry", () => {
let testConfigDir = ""
@@ -17,13 +19,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
})
describe("#given opencode.json has a bare legacy plugin entry", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", async () => {
// given
writeFileSync(
join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n",
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -37,13 +41,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
})
describe("#given opencode.json has a version-pinned legacy entry", () => {
it("#then preserves the version suffix", () => {
it("#then preserves the version suffix", async () => {
// given
writeFileSync(
join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n",
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -57,13 +63,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
})
describe("#given both canonical and legacy entries exist", () => {
it("#then removes legacy entry and keeps canonical", () => {
it("#then removes legacy entry and keeps canonical", async () => {
// given
writeFileSync(
join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2) + "\n",
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -75,8 +83,9 @@ describe("autoMigrateLegacyPluginEntry", () => {
})
describe("#given no config file exists", () => {
it("#then returns migrated false", () => {
it("#then returns migrated false", async () => {
// given - empty dir
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -88,13 +97,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
})
describe("#given opencode.jsonc has comments and a legacy entry", () => {
it("#then preserves comments and replaces entry", () => {
it("#then preserves comments and replaces entry", async () => {
// given
writeFileSync(
join(testConfigDir, "opencode.jsonc"),
'{\n // my config\n "plugin": ["oh-my-opencode"]\n}\n',
)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -108,11 +119,13 @@ describe("autoMigrateLegacyPluginEntry", () => {
})
describe("#given only canonical entry exists", () => {
it("#then returns migrated false and leaves file untouched", () => {
it("#then returns migrated false and leaves file untouched", async () => {
// given
const original = JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n"
writeFileSync(join(testConfigDir, "opencode.json"), original)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when
const result = autoMigrateLegacyPluginEntry(testConfigDir)
+39
View File
@@ -669,4 +669,43 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).toHaveBeenCalled()
})
it("should ignore stale cached Anthropic limits for older models", async () => {
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
const hook = createPreemptiveCompactionHook(ctx as never, {} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_old_anthropic_limit"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_1" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalled()
})
})
@@ -186,7 +186,7 @@ describe("detectCompletionInSessionMessages", () => {
})
describe("#given semantic completion patterns", () => {
test("#when agent says 'task is complete' #then should detect semantic completion", async () => {
test("#when agent says 'task is complete' without explicit promise #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
@@ -205,10 +205,10 @@ describe("detectCompletionInSessionMessages", () => {
})
// #then
expect(detected).toBe(true)
expect(detected).toBe(false)
})
test("#when agent says 'all items are done' #then should detect semantic completion", async () => {
test("#when agent says 'all items are done' without explicit promise #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
@@ -227,10 +227,10 @@ describe("detectCompletionInSessionMessages", () => {
})
// #then
expect(detected).toBe(true)
expect(detected).toBe(false)
})
test("#when agent says 'nothing left to do' #then should detect semantic completion", async () => {
test("#when agent says 'nothing left to do' without explicit promise #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
@@ -249,10 +249,10 @@ describe("detectCompletionInSessionMessages", () => {
})
// #then
expect(detected).toBe(true)
expect(detected).toBe(false)
})
test("#when agent says 'successfully completed all' #then should detect semantic completion", async () => {
test("#when agent says 'successfully completed all' without explicit promise #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
@@ -271,7 +271,7 @@ describe("detectCompletionInSessionMessages", () => {
})
// #then
expect(detected).toBe(true)
expect(detected).toBe(false)
})
test("#when promise is VERIFIED #then semantic completion should NOT trigger", async () => {
@@ -295,6 +295,75 @@ describe("detectCompletionInSessionMessages", () => {
// #then
expect(detected).toBe(false)
})
test("#when completion text appears inside a quote #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: 'The user wrote: "the task is complete". I am still working.' }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-quoted",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(false)
})
test("#when tool_result says all items are complete #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [
{ type: "tool_result", text: "Background agent report: all items are complete." },
{ type: "text", text: "Still validating the final behavior." },
],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-tool-result-semantic",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(false)
})
test("#when assistant says complete but not actually done #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "The implementation looks complete, but I still need to run the tests." }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-not-actually-done",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(false)
})
})
})
@@ -39,6 +39,8 @@ const SEMANTIC_COMPLETION_PATTERNS = [
/\bnothing\s+(?:left|more|remaining)\s+to\s+(?:do|implement|fix)\b/i,
]
const SEMANTIC_DONE_FALLBACK_ENABLED = false
export function detectSemanticCompletion(text: string): boolean {
return SEMANTIC_COMPLETION_PATTERNS.some((pattern) => pattern.test(text))
}
@@ -65,9 +67,8 @@ export function detectCompletionInTranscript(
const entryText = extractTranscriptEntryText(entry)
if (!entryText) continue
if (pattern.test(entryText)) return true
// Fallback: semantic completion only for DONE promise and assistant entries
const isAssistantEntry = entry.type === "assistant" || entry.type === "text"
if (promise === "DONE" && isAssistantEntry && detectSemanticCompletion(entryText)) {
if (SEMANTIC_DONE_FALLBACK_ENABLED && promise === "DONE" && isAssistantEntry && detectSemanticCompletion(entryText)) {
log("[ralph-loop] WARNING: Semantic completion detected in transcript (agent used natural language instead of <promise>DONE</promise>)")
return true
}
@@ -135,8 +136,7 @@ export async function detectCompletionInSessionMessages(
return true
}
// Fallback: semantic completion only for DONE promise
if (options.promise === "DONE" && detectSemanticCompletion(responseText)) {
if (SEMANTIC_DONE_FALLBACK_ENABLED && options.promise === "DONE" && detectSemanticCompletion(responseText)) {
log("[ralph-loop] WARNING: Semantic completion detected (agent used natural language instead of <promise>DONE</promise>)", {
sessionID: options.sessionID,
})
@@ -1,7 +1,13 @@
import { describe, expect, it } from "bun:test"
import { readMessagesFromSDK, readPartsFromSDK } from "../storage"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
async function importFreshReaders() {
const token = `${Date.now()}-${Math.random()}`
const [{ readMessagesFromSDK, readMessages }, { readPartsFromSDK, readParts }] = await Promise.all([
import(`./messages-reader?test=${token}`),
import(`./parts-reader?test=${token}`),
])
return { readMessagesFromSDK, readPartsFromSDK, readMessages, readParts }
}
function createMockClient(handlers: {
messages?: (sessionID: string) => unknown[]
@@ -28,6 +34,7 @@ function createMockClient(handlers: {
describe("session-recovery storage SDK readers", () => {
it("readPartsFromSDK returns empty array when fetch fails", async () => {
//#given a client that throws on request
const { readPartsFromSDK } = await importFreshReaders()
const client = createMockClient({}) as Parameters<typeof readPartsFromSDK>[0]
//#when readPartsFromSDK is called
@@ -39,6 +46,7 @@ describe("session-recovery storage SDK readers", () => {
it("readPartsFromSDK returns stored parts from SDK response", async () => {
//#given a client that returns a message with parts
const { readPartsFromSDK } = await importFreshReaders()
const sessionID = "ses_test"
const messageID = "msg_test"
const storedParts = [
@@ -58,6 +66,7 @@ describe("session-recovery storage SDK readers", () => {
it("readMessagesFromSDK normalizes and sorts messages", async () => {
//#given a client that returns messages list
const { readMessagesFromSDK } = await importFreshReaders()
const sessionID = "ses_test"
const client = createMockClient({
messages: () => [
@@ -78,8 +87,9 @@ describe("session-recovery storage SDK readers", () => {
])
})
it("readParts returns empty array for nonexistent message", () => {
it("readParts returns empty array for nonexistent message", async () => {
//#given a message ID that has no stored parts
const { readParts } = await importFreshReaders()
//#when readParts is called
const parts = readParts("msg_nonexistent")
@@ -87,8 +97,9 @@ describe("session-recovery storage SDK readers", () => {
expect(parts).toEqual([])
})
it("readMessages returns empty array for nonexistent session", () => {
it("readMessages returns empty array for nonexistent session", async () => {
//#given a session ID that has no stored messages
const { readMessages } = await importFreshReaders()
//#when readMessages is called
const messages = readMessages("ses_nonexistent")
+28 -2
View File
@@ -12,7 +12,6 @@ import {
import type { BoulderState } from "../../features/boulder-state"
import * as sessionState from "../../features/claude-code-session-state"
import * as worktreeDetector from "./worktree-detector"
import * as worktreeDetector from "./worktree-detector"
describe("start-work hook", () => {
let testDir: string
@@ -26,6 +25,9 @@ describe("start-work hook", () => {
}
beforeEach(() => {
sessionState._resetForTesting()
sessionState.registerAgentName("atlas")
sessionState.registerAgentName("sisyphus")
testDir = join(tmpdir(), `start-work-test-${randomUUID()}`)
sisyphusDir = join(testDir, ".sisyphus")
if (!existsSync(testDir)) {
@@ -38,6 +40,7 @@ describe("start-work hook", () => {
})
afterEach(() => {
sessionState._resetForTesting()
clearBoulderState(testDir)
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true })
@@ -409,7 +412,7 @@ describe("start-work hook", () => {
// given
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {},
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
@@ -422,6 +425,29 @@ describe("start-work hook", () => {
// then
expect(output.message.agent).toBe("Atlas (Plan Executor)")
})
test("should keep the current agent when Atlas is unavailable", async () => {
// given
sessionState._resetForTesting()
sessionState.registerAgentName("sisyphus")
sessionState.updateSessionAgent("ses-prometheus-to-sisyphus", "sisyphus")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "ses-prometheus-to-sisyphus" },
output
)
// then
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus")
})
})
describe("worktree support", () => {
+10 -11
View File
@@ -12,7 +12,7 @@ import {
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { getAgentDisplayName } from "../../shared/agent-display-names"
import { updateSessionAgent, isAgentRegistered } from "../../features/claude-code-session-state"
import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request"
@@ -80,14 +80,13 @@ export function createStartWorkHook(ctx: PluginInput) {
if (!promptText.includes("<session-context>")) return
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
const atlasDisplayName = getAgentDisplayName("atlas")
if (isAgentRegistered("atlas") || isAgentRegistered(atlasDisplayName)) {
updateSessionAgent(input.sessionID, "atlas")
if (output.message) {
output.message["agent"] = atlasDisplayName
}
} else {
log(`[${HOOK_NAME}] Atlas agent not available, continuing with current agent`, { sessionID: input.sessionID })
const activeAgent = isAgentRegistered("atlas")
? "atlas"
: getSessionAgent(input.sessionID) ?? "sisyphus"
const activeAgentDisplayName = getAgentDisplayName(activeAgent)
updateSessionAgent(input.sessionID, activeAgent)
if (output.message) {
output.message["agent"] = activeAgentDisplayName
}
const existingState = readBoulderState(ctx.directory)
@@ -116,7 +115,7 @@ The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else {
if (existingState) clearBoulderState(ctx.directory)
const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath)
const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
writeBoulderState(ctx.directory, newState)
contextInfo = `
@@ -223,7 +222,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
} else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath)
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(ctx.directory, newState)
contextInfo += `
+1 -1
View File
@@ -9,7 +9,7 @@ export interface TasksTodowriteDisablerConfig {
export function createTasksTodowriteDisablerHook(
config: TasksTodowriteDisablerConfig,
) {
const isTaskSystemEnabled = config.experimental?.task_system ?? false;
const isTaskSystemEnabled = config.experimental?.task_system ?? true;
return {
"tool.execute.before": async (
@@ -59,7 +59,7 @@ describe("tasks-todowrite-disabler", () => {
})
})
describe("when experimental.task_system is disabled or undefined", () => {
describe("when experimental.task_system is disabled", () => {
test("should not block TodoWrite when flag is false", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: false } })
@@ -78,7 +78,7 @@ describe("tasks-todowrite-disabler", () => {
).resolves.toBeUndefined()
})
test("should not block TodoWrite when experimental is undefined", async () => {
test("should block TodoWrite when experimental is undefined because task_system defaults to enabled", async () => {
// given
const hook = createTasksTodowriteDisablerHook({})
const input = {
@@ -93,7 +93,7 @@ describe("tasks-todowrite-disabler", () => {
// when / then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
).rejects.toThrow("TodoRead/TodoWrite are DISABLED")
})
test("should not block TodoRead when flag is false", async () => {
+7 -1
View File
@@ -246,7 +246,13 @@ describe("parseConfigPartially", () => {
const result = parseConfigPartially({});
expect(result).not.toBeNull();
expect(Object.keys(result!).length).toBe(0);
expect(result).toEqual({
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
});
});
});
@@ -290,7 +290,7 @@ describe("applyAgentConfig builtin override protection", () => {
})
// then
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false)
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", true)
})
test("includes project and global .agents skills in builtin agent awareness", async () => {
+1 -1
View File
@@ -90,7 +90,7 @@ export async function applyAgentConfig(params: {
params.pluginConfig.browser_automation_engine?.provider ?? "playwright";
const currentModel = params.config.model as string | undefined;
const disabledSkills = new Set<string>(params.pluginConfig.disabled_skills ?? []);
const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false;
const useTaskSystem = params.pluginConfig.experimental?.task_system ?? true;
const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false;
const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true;
+3 -3
View File
@@ -1243,7 +1243,7 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined()
})
test("does not deny todowrite/todoread when task_system is undefined", async () => {
test("denies todowrite/todoread when task_system is undefined", async () => {
//#given
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mockResolvedValue: (value: Record<string, unknown>) => void
@@ -1271,8 +1271,8 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
//#then
const agentResult = config.agent as Record<string, { permission?: Record<string, unknown> }>
expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined()
expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined()
expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBe("deny")
expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBe("deny")
})
})
@@ -15,7 +15,7 @@ function createParams(overrides: {
return {
config: { tools: {}, permission: {} } as Record<string, unknown>,
pluginConfig: {
experimental: { task_system: overrides.taskSystem ?? false },
experimental: overrides.taskSystem === undefined ? undefined : { task_system: overrides.taskSystem },
disabled_tools: overrides.disabledTools,
} as OhMyOpenCodeConfig,
agentResult: agentResult as Record<string, unknown>,
@@ -216,6 +216,30 @@ describe("applyToolConfig", () => {
})
})
describe("#given task_system is undefined", () => {
describe("#when applying tool config", () => {
it.each([
"atlas",
"sisyphus",
"hephaestus",
"prometheus",
"sisyphus-junior",
])("#then should deny todo tools for %s agent by default", (agentName) => {
const params = createParams({
agents: [agentName],
})
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.todowrite).toBe("deny")
expect(agent.permission.todoread).toBe("deny")
})
})
})
describe("#given disabled_tools includes 'question'", () => {
let originalConfigContent: string | undefined
let originalCliRunMode: string | undefined
+4 -3
View File
@@ -15,7 +15,7 @@ function getConfigQuestionPermission(): string | null {
}
function agentByKey(agentResult: Record<string, unknown>, key: string): AgentWithPermission | undefined {
return (agentResult[key] ?? agentResult[getAgentDisplayName(key)]) as
return (agentResult[getAgentDisplayName(key)] ?? agentResult[key]) as
| AgentWithPermission
| undefined;
}
@@ -25,7 +25,8 @@ export function applyToolConfig(params: {
pluginConfig: OhMyOpenCodeConfig;
agentResult: Record<string, unknown>;
}): void {
const denyTodoTools = params.pluginConfig.experimental?.task_system
const taskSystemEnabled = params.pluginConfig.experimental?.task_system ?? true
const denyTodoTools = taskSystemEnabled
? { todowrite: "deny", todoread: "deny" }
: {}
@@ -40,7 +41,7 @@ export function applyToolConfig(params: {
LspCodeActionResolve: false,
"task_*": false,
teammate: false,
...(params.pluginConfig.experimental?.task_system
...(taskSystemEnabled
? { todowrite: false, todoread: false }
: {}),
...(skillDeniedByHost
+39
View File
@@ -1,6 +1,7 @@
const { describe, expect, test } = require("bun:test")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
const { createToolRegistry } = require("./tool-registry")
const { builtinTools } = require("../tools")
describe("createToolExecuteBeforeHandler", () => {
test("does not execute subagent question blocker hook for question tool", async () => {
@@ -268,6 +269,44 @@ describe("createToolRegistry", () => {
})
})
})
describe("#given max_tools is lower than or equal to builtin tool count", () => {
describe("#when creating the tool registry", () => {
test("#then it trims to the exact configured cap", () => {
const result = createToolRegistry(
createRegistryInput({
experimental: { max_tools: Object.keys(builtinTools).length },
}),
)
expect(Object.keys(result.filteredTools)).toHaveLength(Object.keys(builtinTools).length)
})
})
})
describe("#given max_tools is set below the full plugin tool count", () => {
describe("#when creating the tool registry", () => {
test("#then it enforces the exact cap deterministically", () => {
const result = createToolRegistry(
createRegistryInput({
experimental: { max_tools: 10 },
}),
)
expect(Object.keys(result.filteredTools)).toHaveLength(10)
})
test("#then it keeps the task tool when lower-priority tools can satisfy the cap", () => {
const result = createToolRegistry(
createRegistryInput({
experimental: { max_tools: 10 },
}),
)
expect(result.filteredTools.task).toBeDefined()
})
})
})
})
export {}
+58 -23
View File
@@ -40,6 +40,63 @@ export type ToolRegistryResult = {
taskSystemEnabled: boolean
}
const LOW_PRIORITY_TOOL_ORDER = [
"session_list",
"session_read",
"session_search",
"session_info",
"interactive_bash",
"look_at",
"call_omo_agent",
"task_create",
"task_get",
"task_list",
"task_update",
"background_output",
"background_cancel",
"hashline_edit",
"ast_grep_replace",
"ast_grep_search",
"glob",
"grep",
"skill_mcp",
"skill",
"task",
"lsp_rename",
"lsp_prepare_rename",
"lsp_find_references",
"lsp_goto_definition",
"lsp_symbols",
"lsp_diagnostics",
] as const
function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void {
const toolNames = Object.keys(filteredTools)
if (toolNames.length <= maxTools) return
const removableToolNames = [
...LOW_PRIORITY_TOOL_ORDER.filter((toolName) => toolNames.includes(toolName)),
...toolNames
.filter((toolName) => !LOW_PRIORITY_TOOL_ORDER.includes(toolName as (typeof LOW_PRIORITY_TOOL_ORDER)[number]))
.sort(),
]
let currentCount = toolNames.length
let removed = 0
for (const toolName of removableToolNames) {
if (currentCount <= maxTools) break
if (!filteredTools[toolName]) continue
delete filteredTools[toolName]
currentCount -= 1
removed += 1
}
log(
`[tool-registry] Trimmed ${removed} tools to satisfy max_tools=${maxTools}. Final plugin tool count=${currentCount}.`,
)
}
export function createToolRegistry(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
@@ -158,29 +215,7 @@ export function createToolRegistry(args: {
const maxTools = pluginConfig.experimental?.max_tools
if (maxTools) {
const estimatedBuiltinTools = 20
const pluginToolBudget = maxTools - estimatedBuiltinTools
const toolEntries = Object.entries(filteredTools)
if (pluginToolBudget > 0 && toolEntries.length > pluginToolBudget) {
const excess = toolEntries.length - pluginToolBudget
log(`[tool-registry] Tool count (${toolEntries.length} plugin + ~${estimatedBuiltinTools} builtin = ~${toolEntries.length + estimatedBuiltinTools}) exceeds max_tools=${maxTools}. Trimming ${excess} lower-priority tools.`)
const lowPriorityTools = [
"session_list", "session_read", "session_search", "session_info",
"call_omo_agent", "interactive_bash", "look_at",
"task_create", "task_get", "task_list", "task_update",
]
let removed = 0
for (const toolName of lowPriorityTools) {
if (removed >= excess) break
if (filteredTools[toolName]) {
delete filteredTools[toolName]
removed += 1
}
}
if (removed < excess) {
log(`[tool-registry] WARNING: Could not trim enough tools. ${toolEntries.length - removed} plugin tools remain.`)
}
}
trimToolsToCap(filteredTools, maxTools)
}
return {
+36 -2
View File
@@ -45,7 +45,7 @@ describe("resolveActualContextLimit", () => {
expect(actualLimit).toBe(1_000_000)
})
it("returns cached limit for Anthropic models when modelContextLimitsCache has entry", () => {
it("returns default 200K for older Anthropic models when 1M mode is disabled", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
@@ -59,7 +59,7 @@ describe("resolveActualContextLimit", () => {
})
// then
expect(actualLimit).toBe(500_000)
expect(actualLimit).toBe(200_000)
})
it("returns default 200K for Anthropic models without cached limit and 1M mode disabled", () => {
@@ -126,6 +126,40 @@ describe("resolveActualContextLimit", () => {
expect(actualLimit).toBe(1_000_000)
})
it("supports Anthropic 4.6 high-variant model IDs without widening older models", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(500_000)
})
it("ignores stale cached limits for older Anthropic models with suffixed IDs", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(200_000)
})
it("returns null for non-Anthropic providers without a cached limit", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
+5 -1
View File
@@ -19,6 +19,10 @@ function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState):
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
function supportsCachedAnthropicLimit(modelID: string): boolean {
return /^claude-(opus|sonnet)-4(?:-|\.)6(?:-high)?$/.test(modelID)
}
export function resolveActualContextLimit(
providerID: string,
modelID: string,
@@ -29,7 +33,7 @@ export function resolveActualContextLimit(
if (explicit1M === 1_000_000) return explicit1M
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
if (cachedLimit) return cachedLimit
if (cachedLimit && supportsCachedAnthropicLimit(modelID)) return cachedLimit
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
+93 -6
View File
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { migrateLegacyPluginEntry } from "./migrate-legacy-plugin-entry"
async function importFreshMigrationModule(): Promise<typeof import("./migrate-legacy-plugin-entry")> {
return import(`./migrate-legacy-plugin-entry?test=${Date.now()}-${Math.random()}`)
}
describe("migrateLegacyPluginEntry", () => {
let testDir = ""
@@ -18,9 +21,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains oh-my-opencode plugin entry", () => {
describe("#when migrating the config", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -34,9 +38,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains bare oh-my-opencode entry", () => {
describe("#when migrating the config", () => {
it("#then replaces with oh-my-openagent", () => {
it("#then replaces with oh-my-openagent", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -50,9 +55,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains pinned oh-my-opencode version", () => {
describe("#when migrating the config", () => {
it("#then preserves the version pin", () => {
it("#then preserves the version pin", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.11.0"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -65,10 +71,11 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json already uses oh-my-openagent", () => {
describe("#when checking for migration", () => {
it("#then returns false and does not modify the file", () => {
it("#then returns false and does not modify the file", async () => {
const configPath = join(testDir, "opencode.json")
const original = JSON.stringify({ plugin: ["oh-my-openagent@latest"] }, null, 2)
writeFileSync(configPath, original)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
@@ -78,9 +85,89 @@ describe("migrateLegacyPluginEntry", () => {
})
})
describe("#given plugin entries contain both canonical and legacy values", () => {
describe("#when migrating the config", () => {
it("#then removes the legacy entry instead of duplicating the canonical one", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const saved = JSON.parse(readFileSync(configPath, "utf-8")) as { plugin: string[] }
expect(saved.plugin).toEqual(["oh-my-openagent"])
})
})
})
describe("#given unrelated strings contain the legacy package name", () => {
describe("#when migrating the config", () => {
it("#then rewrites only plugin entries and preserves unrelated fields", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(
configPath,
JSON.stringify(
{
plugin: ["oh-my-opencode"],
notes: "keep oh-my-opencode in this text field",
paths: ["/tmp/oh-my-opencode/cache"],
},
null,
2,
),
)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const saved = JSON.parse(readFileSync(configPath, "utf-8")) as {
plugin: string[]
notes: string
paths: string[]
}
expect(saved.plugin).toEqual(["oh-my-openagent"])
expect(saved.notes).toBe("keep oh-my-opencode in this text field")
expect(saved.paths).toEqual(["/tmp/oh-my-opencode/cache"])
})
})
})
describe("#given opencode.jsonc contains a nested plugin key before the top-level plugin array", () => {
describe("#when migrating the config", () => {
it("#then rewrites only the top-level plugin array", async () => {
const configPath = join(testDir, "opencode.jsonc")
writeFileSync(
configPath,
`{
"nested": {
"plugin": ["oh-my-opencode"]
},
"plugin": ["oh-my-opencode@latest"]
}
`,
)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const content = readFileSync(configPath, "utf-8")
expect(content).toContain(`"nested": {
"plugin": ["oh-my-opencode"]
}`)
expect(content).toContain(`"plugin": [
"oh-my-openagent@latest"
]`)
})
})
})
describe("#given config file does not exist", () => {
describe("#when attempting migration", () => {
it("#then returns false", () => {
it("#then returns false", async () => {
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(join(testDir, "nonexistent.json"))
expect(result).toBe(false)
+55 -2
View File
@@ -1,8 +1,54 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { applyEdits, modify } from "jsonc-parser"
import { parseJsoncSafe } from "./jsonc-parser"
import { log } from "./logger"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity"
interface OpenCodeConfig {
plugin?: string[]
}
function isLegacyEntry(entry: string): boolean {
return entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)
}
function isCanonicalEntry(entry: string): boolean {
return entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)
}
function toCanonicalEntry(entry: string): string {
if (entry === LEGACY_PLUGIN_NAME) return PLUGIN_NAME
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
return `${PLUGIN_NAME}${entry.slice(LEGACY_PLUGIN_NAME.length)}`
}
return entry
}
function normalizePluginEntries(entries: string[]): string[] {
const hasCanonical = entries.some(isCanonicalEntry)
if (hasCanonical) {
return entries.filter((entry) => !isLegacyEntry(entry))
}
return entries.map((entry) => (isLegacyEntry(entry) ? toCanonicalEntry(entry) : entry))
}
function updateJsoncPluginArray(content: string, pluginEntries: string[]): string | null {
const edits = modify(content, ["plugin"], pluginEntries, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
getInsertionIndex: () => 0,
})
if (edits.length === 0) return null
return applyEdits(content, edits)
}
export function migrateLegacyPluginEntry(configPath: string): boolean {
if (!existsSync(configPath)) return false
@@ -10,8 +56,15 @@ export function migrateLegacyPluginEntry(configPath: string): boolean {
const content = readFileSync(configPath, "utf-8")
if (!content.includes(LEGACY_PLUGIN_NAME)) return false
const updated = content.replaceAll(LEGACY_PLUGIN_NAME, PLUGIN_NAME)
if (updated === content) return false
const parseResult = parseJsoncSafe<OpenCodeConfig>(content)
const pluginEntries = parseResult.data?.plugin
if (!pluginEntries || !pluginEntries.some(isLegacyEntry)) return false
const updatedPluginEntries = normalizePluginEntries(pluginEntries)
const updated = configPath.endsWith(".jsonc")
? updateJsoncPluginArray(content, updatedPluginEntries)
: JSON.stringify({ ...(parseResult.data as OpenCodeConfig), plugin: updatedPluginEntries }, null, 2) + "\n"
if (!updated || updated === content) return false
writeFileSync(configPath, updated, "utf-8")
log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", {
+24
View File
@@ -125,4 +125,28 @@ describe("resolveSkillPathReferences", () => {
//#then
expect(result).toBe("/skills/frontend/scripts/search.py")
})
it("does not resolve traversal paths that escape the base directory", () => {
//#given
const content = "Read @data/../../../../etc/passwd before running"
const basePath = "/skills/frontend"
//#when
const result = resolveSkillPathReferences(content, basePath)
//#then
expect(result).toBe("Read @data/../../../../etc/passwd before running")
})
it("does not resolve directory traversal with trailing slash", () => {
//#given
const content = "Inspect @data/../../../secret/"
const basePath = "/skills/frontend"
//#when
const result = resolveSkillPathReferences(content, basePath)
//#then
expect(result).toBe("Inspect @data/../../../secret/")
})
})
+10 -11
View File
@@ -1,4 +1,4 @@
import { join } from "path"
import { isAbsolute, relative, resolve, sep } from "node:path"
function looksLikeFilePath(path: string): boolean {
if (path.endsWith("/")) return true
@@ -6,22 +6,21 @@ function looksLikeFilePath(path: string): boolean {
return /\.[a-zA-Z0-9]+$/.test(lastSegment)
}
/**
* Resolves @path references in skill content to absolute paths.
*
* Matches @references that contain at least one slash (e.g., @scripts/search.py, @data/)
* to avoid false positives with decorators (@param), JSDoc tags (@ts-ignore), etc.
* Also skips npm scoped packages (@scope/package) by requiring a file extension or trailing slash.
*
* Email addresses are excluded since they have alphanumeric characters before @.
*/
export function resolveSkillPathReferences(content: string, basePath: string): string {
const normalizedBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath
return content.replace(
/(?<![a-zA-Z0-9])@([a-zA-Z0-9_-]+\/[a-zA-Z0-9_.\-\/]*)/g,
(match, relativePath: string) => {
if (!looksLikeFilePath(relativePath)) return match
return join(normalizedBase, relativePath)
const resolvedPath = resolve(normalizedBase, relativePath)
const relativePathFromBase = relative(normalizedBase, resolvedPath)
if (relativePathFromBase.startsWith("..") || isAbsolute(relativePathFromBase)) {
return match
}
if (relativePath.endsWith("/") && !resolvedPath.endsWith(sep)) {
return `${resolvedPath}/`
}
return resolvedPath
}
)
}
@@ -76,7 +76,9 @@ describe("resolveModelForDelegateTask", () => {
})
describe("#when availableModels is empty (cache exists but empty)", () => {
test("#then falls through to category default model (existing behavior)", () => {
test("#then keeps the category default when its provider is connected", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
@@ -87,6 +89,40 @@ describe("resolveModelForDelegateTask", () => {
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
readConnectedProvidersSpy.mockRestore()
})
test("#then skips a disconnected category default and resolves via a connected fallback", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
})
expect(result).toEqual({
model: "openai/gpt-5.4",
variant: "high",
fallbackEntry: { providers: ["openai"], model: "gpt-5.4", variant: "high" },
matchedFallback: true,
})
readConnectedProvidersSpy.mockRestore()
})
test("#then skips disconnected user fallback models and keeps the first connected fallback", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
userFallbackModels: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4", matchedFallback: true })
readConnectedProvidersSpy.mockRestore()
})
})
@@ -225,16 +261,23 @@ describe("resolveModelForDelegateTask", () => {
})
describe("#when availableModels is empty", () => {
test("#then falls through to existing resolution (cache partially ready)", () => {
test("#then uses connected providers to avoid disconnected category defaults", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
{ providers: ["openai"], model: "gpt-5.4" },
],
availableModels: new Set(),
})
expect(result).toBeDefined()
expect(result).toEqual({
model: "openai/gpt-5.4",
fallbackEntry: { providers: ["openai"], model: "gpt-5.4" },
matchedFallback: true,
})
readConnectedProvidersSpy.mockRestore()
})
})
})
+24 -5
View File
@@ -59,6 +59,8 @@ export function resolveModelForDelegateTask(input: {
return { model: userModel }
}
const connectedProviders = input.availableModels.size === 0 ? readConnectedProvidersCache() : null
// Before provider cache is created (first run), skip model resolution entirely.
// OpenCode will use its system default model when no model is specified in the prompt.
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
@@ -77,7 +79,15 @@ export function resolveModelForDelegateTask(input: {
}
if (input.availableModels.size === 0) {
return { model: categoryDefault }
const categoryProvider = categoryDefault.includes("/") ? categoryDefault.split("/")[0] : undefined
if (!connectedProviders || !categoryProvider || connectedProviders.includes(categoryProvider)) {
return { model: categoryDefault }
}
log("[resolveModelForDelegateTask] skipping disconnected category default on cold cache", {
categoryDefault,
connectedProviders,
})
}
const parts = categoryDefault.split("/")
@@ -95,9 +105,19 @@ export function resolveModelForDelegateTask(input: {
const userFallbackModels = input.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (input.availableModels.size === 0) {
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined
if (first) {
return { model: first.baseModel, variant: first.variant, matchedFallback: true }
for (const fallbackModel of userFallbackModels) {
const parsedFallback = parseUserFallbackModel(fallbackModel)
if (!parsedFallback) continue
if (
connectedProviders &&
parsedFallback.providerHint &&
!parsedFallback.providerHint.some((provider) => connectedProviders.includes(provider))
) {
continue
}
return { model: parsedFallback.baseModel, variant: parsedFallback.variant, matchedFallback: true }
}
} else {
for (const fallbackModel of userFallbackModels) {
@@ -115,7 +135,6 @@ export function resolveModelForDelegateTask(input: {
const fallbackChain = input.fallbackChain
if (fallbackChain && fallbackChain.length > 0) {
if (input.availableModels.size === 0) {
const connectedProviders = readConnectedProvidersCache()
if (connectedProviders) {
const connectedSet = new Set(connectedProviders)
for (const entry of fallbackChain) {
+4 -4
View File
@@ -76,13 +76,13 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
**DO NOT provide both.** If category is provided, subagent_type is ignored.
**DO NOT provide both.** category and subagent_type are mutually exclusive.
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
- category: Use predefined category Spawns Sisyphus-Junior with category config
Available categories:
${categoryList}
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
- subagent_type: Use a specific callable non-primary agent directly (for example: explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
- command: The command that triggered this task (optional, for slash command tracking).
@@ -102,7 +102,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Valid values: explore, librarian, oracle, metis, momus"),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Must be a callable non-primary agent name returned by app.agents()."),
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
command: tool.schema.string().optional().describe("The command that triggered this task"),
},
@@ -115,7 +115,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
` - You provided: category="${args.category}", subagent_type="${args.subagent_type}"\n` +
` - Use category for task delegation (e.g., category="${categoryExamples.split(", ")[0]}")\n` +
` - Use subagent_type for direct agent invocation (e.g., subagent_type="explore")\n` +
` - Valid subagent_type values: explore, librarian, oracle, metis, momus`
` - subagent_type must be a callable non-primary agent name returned by app.agents()`
)
}
if (args.category) {
+28 -1
View File
@@ -616,6 +616,33 @@ describe("skill tool - browserProvider forwarding", () => {
})
describe("skill tool - nativeSkills integration", () => {
it("includes native skills in the description even when skills are pre-seeded", async () => {
//#given
const tool = createSkillTool({
skills: [createMockSkill("seeded-skill")],
nativeSkills: {
async all() {
return [{
name: "native-visible-skill",
description: "Native skill exposed from config",
location: "/external/skills/native-visible-skill/SKILL.md",
content: "Native visible skill body",
}]
},
async get() { return undefined },
async dirs() { return [] },
},
})
//#when
expect(tool.description).toContain("seeded-skill")
await tool.execute({ name: "native-visible-skill" }, mockContext)
//#then
expect(tool.description).toContain("seeded-skill")
expect(tool.description).toContain("native-visible-skill")
})
it("merges native skills exposed by PluginInput.skills.all()", async () => {
//#given
const tool = createSkillTool({
@@ -639,6 +666,6 @@ describe("skill tool - nativeSkills integration", () => {
//#then
expect(result).toContain("external-plugin-skill")
expect(result).toContain("Test skill body content")
expect(result).toContain("External plugin skill body")
})
})
+12 -2
View File
@@ -105,6 +105,11 @@ async function extractSkillBody(skill: LoadedSkill): Promise<string> {
return templateMatch ? templateMatch[1].trim() : fullTemplate
}
if (skill.scope === "config" && skill.definition.template) {
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template
}
if (skill.path) {
return extractSkillTemplate(skill)
}
@@ -235,11 +240,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return cachedDescription
}
// Eagerly build description when callers pre-provide skills/commands.
if (options.skills !== undefined) {
const skillInfos = options.skills.map(loadedSkillToInfo)
const commandsForDescription = options.commands ?? []
cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription)
if (options.nativeSkills) {
void buildDescription()
}
} else if (options.commands !== undefined) {
cachedDescription = formatCombinedDescription([], options.commands)
} else {
@@ -248,6 +255,9 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return tool({
get description() {
if (cachedDescription === null) {
void buildDescription()
}
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
args: {
@@ -259,8 +269,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
},
async execute(args: SkillArgs, ctx?: { agent?: string }) {
const skills = await getSkills()
cachedDescription = null
const commands = getCommands()
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
const requestedName = args.name.replace(/^\//, "")