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:
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 += `
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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/")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(/^\//, "")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user