Merge pull request #3492 from code-yeongyu/refactor/legacy-plugin-decoupling
refactor: modernize plugin entry to V1 format and decouple legacy/tightly-coupled code
This commit is contained in:
@@ -11,7 +11,7 @@ OpenCode plugin (npm: `oh-my-opencode`, dual-published as `oh-my-openagent` duri
|
|||||||
```
|
```
|
||||||
oh-my-opencode/
|
oh-my-opencode/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
|
│ ├── index.ts # Plugin entry: default export `pluginModule`, shape `{ id, server }`
|
||||||
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
|
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
|
||||||
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
||||||
│ ├── hooks/ # 52 lifecycle hooks across dedicated modules and standalone files
|
│ ├── hooks/ # 52 lifecycle hooks across dedicated modules and standalone files
|
||||||
@@ -33,7 +33,7 @@ oh-my-opencode/
|
|||||||
## INITIALIZATION FLOW
|
## INITIALIZATION FLOW
|
||||||
|
|
||||||
```
|
```
|
||||||
OhMyOpenCodePlugin(ctx)
|
pluginModule.server(input, options)
|
||||||
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
||||||
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
||||||
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ After making changes, you can test your local build in OpenCode:
|
|||||||
```
|
```
|
||||||
oh-my-opencode/
|
oh-my-opencode/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # Plugin entry (OhMyOpenCodePlugin)
|
│ ├── index.ts # Plugin entry (V1 PluginModule, default export)
|
||||||
│ ├── plugin-config.ts # JSONC multi-level config (Zod v4)
|
│ ├── plugin-config.ts # JSONC multi-level config (Zod v4)
|
||||||
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
||||||
│ ├── hooks/ # 52 lifecycle hooks across 55 dedicated modules
|
│ ├── hooks/ # 52 lifecycle hooks across 55 dedicated modules
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `index.ts` | Plugin entry, exports `OhMyOpenCodePlugin` |
|
| `index.ts` | Plugin entry, default-exports `pluginModule: PluginModule` with `{ id, server }` |
|
||||||
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
|
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
|
||||||
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
||||||
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
|
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"
|
|||||||
import type { HookName, OhMyOpenCodeConfig } from "./config"
|
import type { HookName, OhMyOpenCodeConfig } from "./config"
|
||||||
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
|
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
|
||||||
import type { BackgroundManager } from "./features/background-agent"
|
import type { BackgroundManager } from "./features/background-agent"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||||
import type { PluginContext } from "./plugin/types"
|
import type { PluginContext } from "./plugin/types"
|
||||||
import type { ModelCacheState } from "./plugin-state"
|
import type { ModelCacheState } from "./plugin-state"
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function createHooks(args: {
|
|||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
backgroundManager: BackgroundManager
|
backgroundManager: BackgroundManager
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
isHookEnabled: (hookName: HookName) => boolean
|
isHookEnabled: (hookName: HookName) => boolean
|
||||||
safeHookEnabled: boolean
|
safeHookEnabled: boolean
|
||||||
mergedSkills: LoadedSkill[]
|
mergedSkills: LoadedSkill[]
|
||||||
@@ -46,6 +48,7 @@ export function createHooks(args: {
|
|||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
mergedSkills,
|
mergedSkills,
|
||||||
@@ -56,6 +59,7 @@ export function createHooks(args: {
|
|||||||
ctx,
|
ctx,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PluginContext, TmuxConfig } from "./plugin/types"
|
|||||||
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
|
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
|
||||||
import { BackgroundManager } from "./features/background-agent"
|
import { BackgroundManager } from "./features/background-agent"
|
||||||
import { SkillMcpManager } from "./features/skill-mcp-manager"
|
import { SkillMcpManager } from "./features/skill-mcp-manager"
|
||||||
|
import { createModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||||
import { initTaskToastManager } from "./features/task-toast-manager"
|
import { initTaskToastManager } from "./features/task-toast-manager"
|
||||||
import { TmuxSessionManager } from "./features/tmux-subagent"
|
import { TmuxSessionManager } from "./features/tmux-subagent"
|
||||||
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
|
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
|
||||||
@@ -12,6 +13,7 @@ import { registerManagerForCleanup } from "./features/background-agent/process-c
|
|||||||
import { createConfigHandler } from "./plugin-handlers"
|
import { createConfigHandler } from "./plugin-handlers"
|
||||||
import { log } from "./shared"
|
import { log } from "./shared"
|
||||||
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
|
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||||
|
|
||||||
type CreateManagersDeps = {
|
type CreateManagersDeps = {
|
||||||
BackgroundManagerClass: typeof BackgroundManager
|
BackgroundManagerClass: typeof BackgroundManager
|
||||||
@@ -38,6 +40,7 @@ export type Managers = {
|
|||||||
backgroundManager: BackgroundManager
|
backgroundManager: BackgroundManager
|
||||||
skillMcpManager: SkillMcpManager
|
skillMcpManager: SkillMcpManager
|
||||||
configHandler: ReturnType<typeof createConfigHandler>
|
configHandler: ReturnType<typeof createConfigHandler>
|
||||||
|
modelFallbackControllerAccessor: ModelFallbackControllerAccessor
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createManagers(args: {
|
export function createManagers(args: {
|
||||||
@@ -119,11 +122,13 @@ export function createManagers(args: {
|
|||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
})
|
})
|
||||||
|
const modelFallbackControllerAccessor = createModelFallbackControllerAccessor()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tmuxSessionManager,
|
tmuxSessionManager,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
skillMcpManager,
|
skillMcpManager,
|
||||||
configHandler,
|
configHandler,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ type CreateToolsResult = {
|
|||||||
export async function createTools(args: {
|
export async function createTools(args: {
|
||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
|
||||||
}): Promise<CreateToolsResult> {
|
}): Promise<CreateToolsResult> {
|
||||||
const { ctx, pluginConfig, managers } = args
|
const { ctx, pluginConfig, managers } = args
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -14,7 +14,13 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
|
|||||||
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
|
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
|
||||||
|
|
||||||
export { createThinkModeHook } from "./think-mode";
|
export { createThinkModeHook } from "./think-mode";
|
||||||
export { createModelFallbackHook, setPendingModelFallback, clearPendingModelFallback, type ModelFallbackState } from "./model-fallback/hook";
|
export {
|
||||||
|
createModelFallbackHook,
|
||||||
|
setPendingModelFallback,
|
||||||
|
clearPendingModelFallback,
|
||||||
|
type ModelFallbackHook,
|
||||||
|
type ModelFallbackState,
|
||||||
|
} from "./model-fallback/hook";
|
||||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||||
export { createRulesInjectorHook } from "./rules-injector";
|
export { createRulesInjectorHook } from "./rules-injector";
|
||||||
export { createBackgroundNotificationHook } from "./background-notification"
|
export { createBackgroundNotificationHook } from "./background-notification"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import type { ModelFallbackStateController } from "./fallback-state-controller"
|
||||||
|
|
||||||
|
export type ModelFallbackControllerAccessor = {
|
||||||
|
register: (controller: ModelFallbackStateController) => void
|
||||||
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
||||||
|
clearSessionFallbackChain: (sessionID: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModelFallbackControllerAccessor(): ModelFallbackControllerAccessor {
|
||||||
|
let controller: ModelFallbackStateController | null = null
|
||||||
|
|
||||||
|
function register(nextController: ModelFallbackStateController): void {
|
||||||
|
controller = nextController
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
||||||
|
controller?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionFallbackChain(sessionID: string): void {
|
||||||
|
controller?.clearSessionFallbackChain(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
register,
|
||||||
|
setSessionFallbackChain,
|
||||||
|
clearSessionFallbackChain,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
|
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { getNextReachableFallback } from "./next-fallback"
|
||||||
|
|
||||||
|
type ModelFallbackStateLike = {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
fallbackChain: FallbackEntry[]
|
||||||
|
attemptCount: number
|
||||||
|
pending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelFallbackStateController = {
|
||||||
|
lastToastKey: Map<string, string>
|
||||||
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
||||||
|
clearSessionFallbackChain: (sessionID: string) => void
|
||||||
|
setPendingModelFallback: (
|
||||||
|
sessionID: string,
|
||||||
|
agentName: string,
|
||||||
|
currentProviderID: string,
|
||||||
|
currentModelID: string,
|
||||||
|
) => boolean
|
||||||
|
getNextFallback: (sessionID: string) => ReturnType<typeof getNextReachableFallback>
|
||||||
|
clearPendingModelFallback: (sessionID: string) => void
|
||||||
|
hasPendingModelFallback: (sessionID: string) => boolean
|
||||||
|
getFallbackState: (sessionID: string) => ModelFallbackStateLike | undefined
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModelFallbackStateController(input: {
|
||||||
|
pendingModelFallbacks: Map<string, ModelFallbackStateLike>
|
||||||
|
lastToastKey: Map<string, string>
|
||||||
|
sessionFallbackChains: Map<string, FallbackEntry[]>
|
||||||
|
}): ModelFallbackStateController {
|
||||||
|
const { pendingModelFallbacks, lastToastKey, sessionFallbackChains } = input
|
||||||
|
|
||||||
|
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
||||||
|
if (!sessionID) return
|
||||||
|
sessionFallbackChains.set(sessionID, fallbackChain?.length ? fallbackChain : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionFallbackChain(sessionID: string): void {
|
||||||
|
sessionFallbackChains.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPendingModelFallback(
|
||||||
|
sessionID: string,
|
||||||
|
agentName: string,
|
||||||
|
currentProviderID: string,
|
||||||
|
currentModelID: string,
|
||||||
|
): boolean {
|
||||||
|
const agentKey = getAgentConfigKey(agentName)
|
||||||
|
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
|
||||||
|
const fallbackChain = sessionFallbackChains.get(sessionID) ?? requirements?.fallbackChain
|
||||||
|
|
||||||
|
if (!fallbackChain?.length) {
|
||||||
|
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = pendingModelFallbacks.get(sessionID)
|
||||||
|
if (!existing) {
|
||||||
|
pendingModelFallbacks.set(sessionID, {
|
||||||
|
providerID: currentProviderID,
|
||||||
|
modelID: currentModelID,
|
||||||
|
fallbackChain,
|
||||||
|
attemptCount: 0,
|
||||||
|
pending: true,
|
||||||
|
})
|
||||||
|
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.pending) {
|
||||||
|
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.providerID = currentProviderID
|
||||||
|
existing.modelID = currentModelID
|
||||||
|
existing.pending = true
|
||||||
|
if (existing.attemptCount >= existing.fallbackChain.length) {
|
||||||
|
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextFallback(sessionID: string): ReturnType<typeof getNextReachableFallback> {
|
||||||
|
const state = pendingModelFallbacks.get(sessionID)
|
||||||
|
if (!state?.pending) return null
|
||||||
|
|
||||||
|
const fallback = getNextReachableFallback(sessionID, state)
|
||||||
|
if (fallback) return fallback
|
||||||
|
|
||||||
|
log("[model-fallback] No more fallbacks for session: " + sessionID)
|
||||||
|
pendingModelFallbacks.delete(sessionID)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingModelFallback(sessionID: string): void {
|
||||||
|
pendingModelFallbacks.delete(sessionID)
|
||||||
|
lastToastKey.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingModelFallback(sessionID: string): boolean {
|
||||||
|
return pendingModelFallbacks.get(sessionID)?.pending === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackState(sessionID: string): ModelFallbackStateLike | undefined {
|
||||||
|
return pendingModelFallbacks.get(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
pendingModelFallbacks.clear()
|
||||||
|
lastToastKey.clear()
|
||||||
|
sessionFallbackChains.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastToastKey,
|
||||||
|
setSessionFallbackChain,
|
||||||
|
clearSessionFallbackChain,
|
||||||
|
setPendingModelFallback,
|
||||||
|
getNextFallback,
|
||||||
|
clearPendingModelFallback,
|
||||||
|
hasPendingModelFallback,
|
||||||
|
getFallbackState,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,22 +70,23 @@ const {
|
|||||||
setPendingModelFallback,
|
setPendingModelFallback,
|
||||||
} = await importFreshModelFallbackHookModule()
|
} = await importFreshModelFallbackHookModule()
|
||||||
|
|
||||||
|
type ModelFallbackHook = ReturnType<typeof createModelFallbackHook>
|
||||||
|
|
||||||
describe("model fallback hook", () => {
|
describe("model fallback hook", () => {
|
||||||
|
let modelFallback: ModelFallbackHook
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
modelFallback = createModelFallbackHook()
|
||||||
readConnectedProvidersCacheMock.mockReturnValue(null)
|
readConnectedProvidersCacheMock.mockReturnValue(null)
|
||||||
readProviderModelsCacheMock.mockReturnValue(null)
|
readProviderModelsCacheMock.mockReturnValue(null)
|
||||||
readConnectedProvidersCacheMock.mockClear()
|
readConnectedProvidersCacheMock.mockClear()
|
||||||
readProviderModelsCacheMock.mockClear()
|
readProviderModelsCacheMock.mockClear()
|
||||||
selectFallbackProviderMock.mockClear()
|
selectFallbackProviderMock.mockClear()
|
||||||
|
|
||||||
clearPendingModelFallback("ses_model_fallback_main")
|
|
||||||
clearPendingModelFallback("ses_model_fallback_ghcp")
|
|
||||||
clearPendingModelFallback("ses_model_fallback_google")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies pending fallback on chat.message by overriding model", async () => {
|
test("applies pending fallback on chat.message by overriding model", async () => {
|
||||||
//#given
|
//#given
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -93,6 +94,7 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
"ses_model_fallback_main",
|
"ses_model_fallback_main",
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -123,7 +125,7 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
test("preserves fallback progression across repeated session.error retries", async () => {
|
test("preserves fallback progression across repeated session.error retries", async () => {
|
||||||
//#given
|
//#given
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -132,7 +134,7 @@ describe("model fallback hook", () => {
|
|||||||
const sessionID = "ses_model_fallback_main"
|
const sessionID = "ses_model_fallback_main"
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
|
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
const firstOutput = {
|
const firstOutput = {
|
||||||
@@ -154,7 +156,7 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
//#when - second error re-arms fallback and should advance to next entry
|
//#when - second error re-arms fallback and should advance to next entry
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
|
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
const secondOutput = {
|
const secondOutput = {
|
||||||
@@ -176,16 +178,18 @@ describe("model fallback hook", () => {
|
|||||||
test("does not re-arm fallback when one is already pending", () => {
|
test("does not re-arm fallback when one is already pending", () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_pending_guard"
|
const sessionID = "ses_model_fallback_pending_guard"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const firstSet = setPendingModelFallback(
|
const firstSet = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"claude-opus-4-7-thinking",
|
"claude-opus-4-7-thinking",
|
||||||
)
|
)
|
||||||
const secondSet = setPendingModelFallback(
|
const secondSet = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -195,28 +199,29 @@ describe("model fallback hook", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(firstSet).toBe(true)
|
expect(firstSet).toBe(true)
|
||||||
expect(secondSet).toBe(false)
|
expect(secondSet).toBe(false)
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips no-op fallback entries that resolve to same provider/model", async () => {
|
test("skips no-op fallback entries that resolve to same provider/model", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_noop_skip"
|
const sessionID = "ses_model_fallback_noop_skip"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(
|
setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -239,28 +244,29 @@ describe("model fallback hook", () => {
|
|||||||
providerID: "opencode",
|
providerID: "opencode",
|
||||||
modelID: "kimi-k2.5-free",
|
modelID: "kimi-k2.5-free",
|
||||||
})
|
})
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips no-op fallback entries even when variant differs", async () => {
|
test("skips no-op fallback entries even when variant differs", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_noop_variant_skip"
|
const sessionID = "ses_model_fallback_noop_variant_skip"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
|
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
|
||||||
{ providers: ["quotio"], model: "gpt-5.2" },
|
{ providers: ["quotio"], model: "gpt-5.2" },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(
|
setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"quotio",
|
"quotio",
|
||||||
@@ -285,28 +291,29 @@ describe("model fallback hook", () => {
|
|||||||
modelID: "gpt-5.2",
|
modelID: "gpt-5.2",
|
||||||
})
|
})
|
||||||
expect(output.message["variant"]).toBeUndefined()
|
expect(output.message["variant"]).toBeUndefined()
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
|
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_preferred_provider"
|
const sessionID = "ses_model_fallback_preferred_provider"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["provider-y"], model: "fallback-model" },
|
{ providers: ["provider-y"], model: "fallback-model" },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(
|
setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"provider-x",
|
"provider-x",
|
||||||
@@ -329,17 +336,18 @@ describe("model fallback hook", () => {
|
|||||||
providerID: "provider-x",
|
providerID: "provider-x",
|
||||||
modelID: "fallback-model",
|
modelID: "fallback-model",
|
||||||
})
|
})
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
|
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_explicit_none"
|
const sessionID = "ses_model_fallback_explicit_none"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
setSessionFallbackChain(sessionID, undefined)
|
setSessionFallbackChain(modelFallback, sessionID, undefined)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Junior",
|
"Sisyphus - Junior",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -348,7 +356,7 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(set).toBe(false)
|
expect(set).toBe(false)
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows toast when fallback is applied", async () => {
|
test("shows toast when fallback is applied", async () => {
|
||||||
@@ -366,6 +374,7 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
hook,
|
||||||
"ses_model_fallback_toast",
|
"ses_model_fallback_toast",
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -392,9 +401,9 @@ describe("model fallback hook", () => {
|
|||||||
test("transforms model names for github-copilot provider via fallback chain", async () => {
|
test("transforms model names for github-copilot provider via fallback chain", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_ghcp"
|
const sessionID = "ses_model_fallback_ghcp"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -402,11 +411,12 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set a custom fallback chain that routes through github-copilot
|
// Set a custom fallback chain that routes through github-copilot
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
|
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
|
||||||
])
|
])
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Atlas - Plan Executor",
|
"Atlas - Plan Executor",
|
||||||
"github-copilot",
|
"github-copilot",
|
||||||
@@ -430,15 +440,15 @@ describe("model fallback hook", () => {
|
|||||||
modelID: "claude-sonnet-4.6",
|
modelID: "claude-sonnet-4.6",
|
||||||
})
|
})
|
||||||
|
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves canonical google preview model names via fallback chain", async () => {
|
test("preserves canonical google preview model names via fallback chain", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_google"
|
const sessionID = "ses_model_fallback_google"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -446,11 +456,12 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set a custom fallback chain that routes through google
|
// Set a custom fallback chain that routes through google
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
|
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
|
||||||
])
|
])
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Oracle",
|
"Oracle",
|
||||||
"google",
|
"google",
|
||||||
@@ -474,7 +485,7 @@ describe("model fallback hook", () => {
|
|||||||
modelID: "gemini-3.1-pro-preview",
|
modelID: "gemini-3.1-pro-preview",
|
||||||
})
|
})
|
||||||
|
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
|
||||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
|
||||||
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache"
|
|
||||||
import { selectFallbackProvider } from "../../shared/model-error-classifier"
|
|
||||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
|
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
|
||||||
import { applyFallbackToChatMessage } from "./chat-message-fallback-handler"
|
import { applyFallbackToChatMessage } from "./chat-message-fallback-handler"
|
||||||
import { getNextReachableFallback } from "./next-fallback"
|
import {
|
||||||
|
createModelFallbackStateController,
|
||||||
|
type ModelFallbackStateController,
|
||||||
|
} from "./fallback-state-controller"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "./controller-accessor"
|
||||||
|
|
||||||
type FallbackToast = (input: {
|
type FallbackToast = (input: {
|
||||||
title: string
|
title: string
|
||||||
@@ -31,30 +29,45 @@ export type ModelFallbackState = {
|
|||||||
pending: boolean
|
pending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type ModelFallbackControllerWithState = Pick<
|
||||||
* Map of sessionID -> pending model fallback state
|
ModelFallbackStateController,
|
||||||
* When a model error occurs, we store the fallback info here.
|
| "lastToastKey"
|
||||||
* The next chat.message call will use this to switch to the fallback model.
|
| "setSessionFallbackChain"
|
||||||
*/
|
| "clearSessionFallbackChain"
|
||||||
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
|
| "setPendingModelFallback"
|
||||||
const lastToastKey = new Map<string, string>()
|
| "getNextFallback"
|
||||||
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
|
| "clearPendingModelFallback"
|
||||||
|
| "hasPendingModelFallback"
|
||||||
|
| "getFallbackState"
|
||||||
|
| "reset"
|
||||||
|
>
|
||||||
|
|
||||||
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
export type ModelFallbackHook = ModelFallbackControllerWithState & {
|
||||||
if (!sessionID) return
|
"chat.message": (
|
||||||
if (!fallbackChain) {
|
input: ChatMessageInput,
|
||||||
sessionFallbackChains.set(sessionID, [])
|
output: ChatMessageHandlerOutput,
|
||||||
return
|
) => Promise<void>
|
||||||
}
|
|
||||||
if (fallbackChain.length === 0) {
|
|
||||||
sessionFallbackChains.set(sessionID, [])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionFallbackChains.set(sessionID, fallbackChain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionFallbackChain(sessionID: string): void {
|
type ModelFallbackHookArgs = {
|
||||||
sessionFallbackChains.delete(sessionID)
|
toast?: FallbackToast
|
||||||
|
onApplied?: FallbackCallback
|
||||||
|
controllerAccessor?: ModelFallbackControllerAccessor
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionFallbackChain(
|
||||||
|
controller: Pick<ModelFallbackStateController, "setSessionFallbackChain">,
|
||||||
|
sessionID: string,
|
||||||
|
fallbackChain: FallbackEntry[] | undefined,
|
||||||
|
): void {
|
||||||
|
controller.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionFallbackChain(
|
||||||
|
controller: Pick<ModelFallbackStateController, "clearSessionFallbackChain">,
|
||||||
|
sessionID: string,
|
||||||
|
): void {
|
||||||
|
controller.clearSessionFallbackChain(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,56 +75,18 @@ export function clearSessionFallbackChain(sessionID: string): void {
|
|||||||
* Called when a model error is detected in session.error handler.
|
* Called when a model error is detected in session.error handler.
|
||||||
*/
|
*/
|
||||||
export function setPendingModelFallback(
|
export function setPendingModelFallback(
|
||||||
|
controller: Pick<ModelFallbackStateController, "setPendingModelFallback">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
currentProviderID: string,
|
currentProviderID: string,
|
||||||
currentModelID: string,
|
currentModelID: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const agentKey = getAgentConfigKey(agentName)
|
return controller.setPendingModelFallback(
|
||||||
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
|
sessionID,
|
||||||
const hasSessionFallback = sessionFallbackChains.has(sessionID)
|
agentName,
|
||||||
const sessionFallback = sessionFallbackChains.get(sessionID)
|
currentProviderID,
|
||||||
const fallbackChain = hasSessionFallback
|
currentModelID,
|
||||||
? sessionFallback
|
)
|
||||||
: requirements?.fallbackChain
|
|
||||||
|
|
||||||
if (!fallbackChain || fallbackChain.length === 0) {
|
|
||||||
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = pendingModelFallbacks.get(sessionID)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (existing.pending) {
|
|
||||||
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve progression across repeated session.error retries in same session.
|
|
||||||
// We only mark the next turn as pending fallback application.
|
|
||||||
existing.providerID = currentProviderID
|
|
||||||
existing.modelID = currentModelID
|
|
||||||
existing.pending = true
|
|
||||||
if (existing.attemptCount >= existing.fallbackChain.length) {
|
|
||||||
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: ModelFallbackState = {
|
|
||||||
providerID: currentProviderID,
|
|
||||||
modelID: currentModelID,
|
|
||||||
fallbackChain,
|
|
||||||
attemptCount: 0,
|
|
||||||
pending: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingModelFallbacks.set(sessionID, state)
|
|
||||||
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,55 +94,71 @@ export function setPendingModelFallback(
|
|||||||
* Increments attemptCount each time called.
|
* Increments attemptCount each time called.
|
||||||
*/
|
*/
|
||||||
export function getNextFallback(
|
export function getNextFallback(
|
||||||
|
controller: Pick<ModelFallbackStateController, "getNextFallback">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
): { providerID: string; modelID: string; variant?: string } | null {
|
): { providerID: string; modelID: string; variant?: string } | null {
|
||||||
const state = pendingModelFallbacks.get(sessionID)
|
return controller.getNextFallback(sessionID)
|
||||||
if (!state) return null
|
|
||||||
|
|
||||||
if (!state.pending) return null
|
|
||||||
|
|
||||||
const fallback = getNextReachableFallback(sessionID, state)
|
|
||||||
if (fallback) {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[model-fallback] No more fallbacks for session: " + sessionID)
|
|
||||||
pendingModelFallbacks.delete(sessionID)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the pending fallback for a session.
|
* Clears the pending fallback for a session.
|
||||||
* Called after fallback is successfully applied.
|
* Called after fallback is successfully applied.
|
||||||
*/
|
*/
|
||||||
export function clearPendingModelFallback(sessionID: string): void {
|
export function clearPendingModelFallback(
|
||||||
pendingModelFallbacks.delete(sessionID)
|
controller: Pick<ModelFallbackStateController, "clearPendingModelFallback">,
|
||||||
lastToastKey.delete(sessionID)
|
sessionID: string,
|
||||||
|
): void {
|
||||||
|
controller.clearPendingModelFallback(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if there's a pending fallback for a session.
|
* Checks if there's a pending fallback for a session.
|
||||||
*/
|
*/
|
||||||
export function hasPendingModelFallback(sessionID: string): boolean {
|
export function hasPendingModelFallback(
|
||||||
const state = pendingModelFallbacks.get(sessionID)
|
controller: Pick<ModelFallbackStateController, "hasPendingModelFallback">,
|
||||||
return state?.pending === true
|
sessionID: string,
|
||||||
|
): boolean {
|
||||||
|
return controller.hasPendingModelFallback(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current fallback state for a session (for debugging).
|
* Gets the current fallback state for a session (for debugging).
|
||||||
*/
|
*/
|
||||||
export function getFallbackState(sessionID: string): ModelFallbackState | undefined {
|
export function getFallbackState(
|
||||||
return pendingModelFallbacks.get(sessionID)
|
controller: Pick<ModelFallbackStateController, "getFallbackState">,
|
||||||
|
sessionID: string,
|
||||||
|
): ModelFallbackState | undefined {
|
||||||
|
return controller.getFallbackState(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a chat.message hook that applies model fallbacks when pending.
|
* Creates a chat.message hook that applies model fallbacks when pending.
|
||||||
*/
|
*/
|
||||||
export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplied?: FallbackCallback }) {
|
export function createModelFallbackHook(args?: ModelFallbackHookArgs): ModelFallbackHook {
|
||||||
|
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
|
||||||
|
const lastToastKey = new Map<string, string>()
|
||||||
|
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
|
||||||
|
const controller = createModelFallbackStateController({
|
||||||
|
pendingModelFallbacks,
|
||||||
|
lastToastKey,
|
||||||
|
sessionFallbackChains,
|
||||||
|
})
|
||||||
|
|
||||||
|
args?.controllerAccessor?.register(controller)
|
||||||
|
|
||||||
const toast = args?.toast
|
const toast = args?.toast
|
||||||
const onApplied = args?.onApplied
|
const onApplied = args?.onApplied
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
lastToastKey: controller.lastToastKey,
|
||||||
|
setSessionFallbackChain: controller.setSessionFallbackChain,
|
||||||
|
clearSessionFallbackChain: controller.clearSessionFallbackChain,
|
||||||
|
setPendingModelFallback: controller.setPendingModelFallback,
|
||||||
|
getNextFallback: controller.getNextFallback,
|
||||||
|
clearPendingModelFallback: controller.clearPendingModelFallback,
|
||||||
|
hasPendingModelFallback: controller.hasPendingModelFallback,
|
||||||
|
getFallbackState: controller.getFallbackState,
|
||||||
|
reset: controller.reset,
|
||||||
"chat.message": async (
|
"chat.message": async (
|
||||||
input: ChatMessageInput,
|
input: ChatMessageInput,
|
||||||
output: ChatMessageHandlerOutput,
|
output: ChatMessageHandlerOutput,
|
||||||
@@ -175,7 +166,7 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
|
|||||||
const { sessionID } = input
|
const { sessionID } = input
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const fallback = getNextFallback(sessionID)
|
const fallback = getNextFallback(controller, sessionID)
|
||||||
if (!fallback) return
|
if (!fallback) return
|
||||||
|
|
||||||
await applyFallbackToChatMessage({
|
await applyFallbackToChatMessage({
|
||||||
@@ -184,18 +175,15 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
|
|||||||
fallback,
|
fallback,
|
||||||
toast,
|
toast,
|
||||||
onApplied,
|
onApplied,
|
||||||
lastToastKey,
|
lastToastKey: controller.lastToastKey,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets all module-global state for testing.
|
* Resets hook-owned state for testing.
|
||||||
* Clears pending fallbacks, toast keys, and session chains.
|
|
||||||
*/
|
*/
|
||||||
export function _resetForTesting(): void {
|
export function _resetForTesting(controller?: Pick<ModelFallbackStateController, "reset">): void {
|
||||||
pendingModelFallbacks.clear()
|
controller?.reset()
|
||||||
lastToastKey.clear()
|
|
||||||
sessionFallbackChains.clear()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { createModelFallbackControllerAccessor } from "./controller-accessor"
|
||||||
|
export type { ModelFallbackControllerAccessor } from "./controller-accessor"
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
|
import {
|
||||||
|
resolveActualContextLimit,
|
||||||
|
type ContextLimitModelCacheState,
|
||||||
|
} from "../shared/context-limit-resolver"
|
||||||
|
import { log } from "../shared/logger"
|
||||||
|
|
||||||
|
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||||
|
import type {
|
||||||
|
CachedCompactionState,
|
||||||
|
PreemptiveCompactionContext,
|
||||||
|
} from "./preemptive-compaction-types"
|
||||||
|
|
||||||
|
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
|
||||||
|
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
||||||
|
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
|
||||||
|
|
||||||
|
declare function setTimeout(handler: () => void, timeout?: number): unknown
|
||||||
|
declare function clearTimeout(timeoutID: unknown): void
|
||||||
|
|
||||||
|
async function withTimeout<TValue>(
|
||||||
|
promise: Promise<TValue>,
|
||||||
|
timeoutMs: number,
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<TValue> {
|
||||||
|
let timeoutID: unknown
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutID = setTimeout(() => {
|
||||||
|
reject(new Error(errorMessage))
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
return await Promise.race([promise, timeoutPromise]).finally(() => {
|
||||||
|
clearTimeout(timeoutID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPreemptiveCompactionIfNeeded(args: {
|
||||||
|
ctx: PreemptiveCompactionContext
|
||||||
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
|
modelCacheState?: ContextLimitModelCacheState
|
||||||
|
sessionID: string
|
||||||
|
tokenCache: Map<string, CachedCompactionState>
|
||||||
|
compactionInProgress: Set<string>
|
||||||
|
compactedSessions: Set<string>
|
||||||
|
lastCompactionTime: Map<string, number>
|
||||||
|
}): Promise<void> {
|
||||||
|
const {
|
||||||
|
ctx,
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState,
|
||||||
|
sessionID,
|
||||||
|
tokenCache,
|
||||||
|
compactionInProgress,
|
||||||
|
compactedSessions,
|
||||||
|
lastCompactionTime,
|
||||||
|
} = args
|
||||||
|
|
||||||
|
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
|
||||||
|
|
||||||
|
const lastTime = lastCompactionTime.get(sessionID)
|
||||||
|
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
|
||||||
|
|
||||||
|
const cached = tokenCache.get(sessionID)
|
||||||
|
if (!cached) return
|
||||||
|
|
||||||
|
const actualLimit = resolveActualContextLimit(
|
||||||
|
cached.providerID,
|
||||||
|
cached.modelID,
|
||||||
|
modelCacheState,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (actualLimit === null) {
|
||||||
|
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
|
||||||
|
providerID: cached.providerID,
|
||||||
|
modelID: cached.modelID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
|
||||||
|
const usageRatio = totalInputTokens / actualLimit
|
||||||
|
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
|
||||||
|
|
||||||
|
compactionInProgress.add(sessionID)
|
||||||
|
lastCompactionTime.set(sessionID, Date.now())
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||||
|
pluginConfig,
|
||||||
|
sessionID,
|
||||||
|
cached.providerID,
|
||||||
|
cached.modelID,
|
||||||
|
)
|
||||||
|
|
||||||
|
await withTimeout(
|
||||||
|
ctx.client.session.summarize({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: { providerID: targetProviderID, modelID: targetModelID, auto: true },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
}),
|
||||||
|
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
|
||||||
|
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
|
||||||
|
)
|
||||||
|
|
||||||
|
compactedSessions.add(sessionID)
|
||||||
|
} catch (error) {
|
||||||
|
log("[preemptive-compaction] Compaction failed", {
|
||||||
|
sessionID,
|
||||||
|
providerID: cached.providerID,
|
||||||
|
modelID: cached.modelID,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
ctx.client.tui.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Preemptive compaction failed",
|
||||||
|
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 10000,
|
||||||
|
},
|
||||||
|
}).catch((toastError: unknown) => {
|
||||||
|
log("[preemptive-compaction] Failed to show toast", {
|
||||||
|
sessionID,
|
||||||
|
toastError: String(toastError),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
compactionInProgress.delete(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export interface TokenInfo {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cache: { read: number; write: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedCompactionState {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
tokens: TokenInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreemptiveCompactionClient {
|
||||||
|
session: {
|
||||||
|
messages: (input: {
|
||||||
|
path: { id: string }
|
||||||
|
query?: { directory: string }
|
||||||
|
}) => Promise<unknown>
|
||||||
|
summarize: (input: {
|
||||||
|
path: { id: string }
|
||||||
|
body: { providerID: string; modelID: string; auto?: boolean }
|
||||||
|
query: { directory: string }
|
||||||
|
}) => Promise<unknown>
|
||||||
|
}
|
||||||
|
tui: {
|
||||||
|
showToast: (input: {
|
||||||
|
body: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
variant: "warning"
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
}) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreemptiveCompactionContext {
|
||||||
|
client: PreemptiveCompactionClient
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
@@ -1,69 +1,16 @@
|
|||||||
import { log } from "../shared/logger"
|
|
||||||
import type { OhMyOpenCodeConfig } from "../config"
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
import {
|
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
|
||||||
resolveActualContextLimit,
|
|
||||||
type ContextLimitModelCacheState,
|
|
||||||
} from "../shared/context-limit-resolver"
|
|
||||||
|
|
||||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
|
||||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||||
|
import { runPreemptiveCompactionIfNeeded } from "./preemptive-compaction-trigger"
|
||||||
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
|
import type {
|
||||||
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
CachedCompactionState,
|
||||||
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
|
PreemptiveCompactionContext,
|
||||||
|
TokenInfo,
|
||||||
declare function setTimeout(handler: () => void, timeout?: number): unknown
|
} from "./preemptive-compaction-types"
|
||||||
declare function clearTimeout(timeoutID: unknown): void
|
|
||||||
|
|
||||||
interface TokenInfo {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
reasoning: number
|
|
||||||
cache: { read: number; write: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CachedCompactionState {
|
|
||||||
providerID: string
|
|
||||||
modelID: string
|
|
||||||
tokens: TokenInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withTimeout<TValue>(
|
|
||||||
promise: Promise<TValue>,
|
|
||||||
timeoutMs: number,
|
|
||||||
errorMessage: string,
|
|
||||||
): Promise<TValue> {
|
|
||||||
let timeoutID: unknown
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
timeoutID = setTimeout(() => {
|
|
||||||
reject(new Error(errorMessage))
|
|
||||||
}, timeoutMs)
|
|
||||||
})
|
|
||||||
|
|
||||||
return await Promise.race([promise, timeoutPromise]).finally(() => {
|
|
||||||
clearTimeout(timeoutID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginInput = {
|
|
||||||
client: {
|
|
||||||
session: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
messages: (...args: any[]) => any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
summarize: (...args: any[]) => any
|
|
||||||
}
|
|
||||||
tui: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
showToast: (...args: any[]) => any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPreemptiveCompactionHook(
|
export function createPreemptiveCompactionHook(
|
||||||
ctx: PluginInput,
|
ctx: PreemptiveCompactionContext,
|
||||||
pluginConfig: OhMyOpenCodeConfig,
|
pluginConfig: OhMyOpenCodeConfig,
|
||||||
modelCacheState?: ContextLimitModelCacheState,
|
modelCacheState?: ContextLimitModelCacheState,
|
||||||
) {
|
) {
|
||||||
@@ -84,78 +31,16 @@ export function createPreemptiveCompactionHook(
|
|||||||
input: { tool: string; sessionID: string; callID: string },
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
_output: { title: string; output: string; metadata: unknown }
|
_output: { title: string; output: string; metadata: unknown }
|
||||||
) => {
|
) => {
|
||||||
const { sessionID } = input
|
await runPreemptiveCompactionIfNeeded({
|
||||||
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
|
ctx,
|
||||||
|
pluginConfig,
|
||||||
const lastTime = lastCompactionTime.get(sessionID)
|
|
||||||
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
|
|
||||||
|
|
||||||
const cached = tokenCache.get(sessionID)
|
|
||||||
if (!cached) return
|
|
||||||
|
|
||||||
const actualLimit = resolveActualContextLimit(
|
|
||||||
cached.providerID,
|
|
||||||
cached.modelID,
|
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
)
|
sessionID: input.sessionID,
|
||||||
|
tokenCache,
|
||||||
if (actualLimit === null) {
|
compactionInProgress,
|
||||||
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
|
compactedSessions,
|
||||||
providerID: cached.providerID,
|
lastCompactionTime,
|
||||||
modelID: cached.modelID,
|
})
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
|
|
||||||
const usageRatio = totalInputTokens / actualLimit
|
|
||||||
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
|
|
||||||
|
|
||||||
compactionInProgress.add(sessionID)
|
|
||||||
lastCompactionTime.set(sessionID, Date.now())
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
|
||||||
pluginConfig,
|
|
||||||
sessionID,
|
|
||||||
cached.providerID,
|
|
||||||
cached.modelID,
|
|
||||||
)
|
|
||||||
|
|
||||||
await withTimeout(
|
|
||||||
ctx.client.session.summarize({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
}),
|
|
||||||
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
|
|
||||||
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
|
|
||||||
)
|
|
||||||
|
|
||||||
compactedSessions.add(sessionID)
|
|
||||||
} catch (error) {
|
|
||||||
log("[preemptive-compaction] Compaction failed", {
|
|
||||||
sessionID,
|
|
||||||
providerID: cached.providerID,
|
|
||||||
modelID: cached.modelID,
|
|
||||||
error: String(error),
|
|
||||||
})
|
|
||||||
ctx.client.tui.showToast({
|
|
||||||
body: {
|
|
||||||
title: "Preemptive compaction failed",
|
|
||||||
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
|
|
||||||
variant: "warning",
|
|
||||||
duration: 10000,
|
|
||||||
},
|
|
||||||
}).catch((toastError: unknown) => {
|
|
||||||
log("[preemptive-compaction] Failed to show toast", {
|
|
||||||
sessionID,
|
|
||||||
toastError: String(toastError),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
compactionInProgress.delete(sessionID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ function createDeferred(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitUntil(condition: () => boolean): Promise<void> {
|
async function waitUntil(shouldTrigger: () => boolean): Promise<void> {
|
||||||
for (let index = 0; index < 100; index++) {
|
for (let index = 0; index < 100; index++) {
|
||||||
if (condition()) {
|
if (shouldTrigger()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
|
import { parseModelString } from "../../shared/model-string-parser"
|
||||||
|
|
||||||
export function buildRetryModelPayload(
|
export function buildRetryModelPayload(
|
||||||
model: string,
|
model: string,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
type EventProperties = Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventInfo(properties: EventProperties): Record<string, unknown> | undefined {
|
||||||
|
const info = properties?.info
|
||||||
|
return isRecord(info) ? info : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionID(properties: EventProperties): string | undefined {
|
||||||
|
const sessionID = properties?.sessionID
|
||||||
|
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
|
||||||
|
|
||||||
|
const sessionId = properties?.sessionId
|
||||||
|
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
|
||||||
|
|
||||||
|
const info = getEventInfo(properties)
|
||||||
|
const infoSessionID = info?.sessionID
|
||||||
|
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
|
||||||
|
|
||||||
|
const infoSessionId = info?.sessionId
|
||||||
|
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventToolName(properties: EventProperties): string | undefined {
|
||||||
|
const tool = properties?.tool
|
||||||
|
if (typeof tool === "string" && tool.length > 0) return tool
|
||||||
|
|
||||||
|
const name = properties?.name
|
||||||
|
if (typeof name === "string" && name.length > 0) return name
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionText(properties: EventProperties): string {
|
||||||
|
const args = properties?.args
|
||||||
|
if (!isRecord(args)) return ""
|
||||||
|
|
||||||
|
const questions = args.questions
|
||||||
|
if (!Array.isArray(questions) || questions.length === 0) return ""
|
||||||
|
|
||||||
|
const firstQuestion = questions[0]
|
||||||
|
if (!isRecord(firstQuestion)) return ""
|
||||||
|
|
||||||
|
const questionText = firstQuestion.question
|
||||||
|
return typeof questionText === "string" ? questionText : ""
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
type Platform,
|
type Platform,
|
||||||
} from "./session-notification-sender"
|
} from "./session-notification-sender"
|
||||||
import * as sessionNotificationSender from "./session-notification-sender"
|
import * as sessionNotificationSender from "./session-notification-sender"
|
||||||
|
import {
|
||||||
|
getEventToolName,
|
||||||
|
getQuestionText,
|
||||||
|
getSessionID,
|
||||||
|
} from "./session-notification-event-properties"
|
||||||
import { hasIncompleteTodos } from "./session-todo-status"
|
import { hasIncompleteTodos } from "./session-todo-status"
|
||||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||||
|
|
||||||
@@ -85,23 +90,6 @@ export function createSessionNotification(
|
|||||||
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
|
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
|
||||||
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
|
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
|
||||||
|
|
||||||
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
|
|
||||||
const sessionID = properties?.sessionID
|
|
||||||
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
|
|
||||||
|
|
||||||
const sessionId = properties?.sessionId
|
|
||||||
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
|
|
||||||
|
|
||||||
const info = properties?.info as Record<string, unknown> | undefined
|
|
||||||
const infoSessionID = info?.sessionID
|
|
||||||
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
|
|
||||||
|
|
||||||
const infoSessionId = info?.sessionId
|
|
||||||
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldNotifyForSession = (sessionID: string): boolean => {
|
const shouldNotifyForSession = (sessionID: string): boolean => {
|
||||||
if (subagentSessions.has(sessionID)) return false
|
if (subagentSessions.has(sessionID)) return false
|
||||||
|
|
||||||
@@ -113,26 +101,6 @@ export function createSessionNotification(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
|
|
||||||
const tool = properties?.tool
|
|
||||||
if (typeof tool === "string" && tool.length > 0) return tool
|
|
||||||
|
|
||||||
const name = properties?.name
|
|
||||||
if (typeof name === "string" && name.length > 0) return name
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
|
|
||||||
const args = properties?.args as Record<string, unknown> | undefined
|
|
||||||
const questions = args?.questions
|
|
||||||
if (!Array.isArray(questions) || questions.length === 0) return ""
|
|
||||||
|
|
||||||
const firstQuestion = questions[0] as Record<string, unknown> | undefined
|
|
||||||
const questionText = firstQuestion?.question
|
|
||||||
return typeof questionText === "string" ? questionText : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (currentPlatform === "unsupported") return
|
if (currentPlatform === "unsupported") return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
function createCompactingHandler(hooks: {
|
||||||
|
compactionContextInjector?: {
|
||||||
|
capture: (sessionID: string) => Promise<void>
|
||||||
|
inject: (sessionID: string) => string
|
||||||
|
}
|
||||||
|
compactionTodoPreserver?: { capture: (sessionID: string) => Promise<void> }
|
||||||
|
claudeCodeHooks?: {
|
||||||
|
"experimental.session.compacting"?: (
|
||||||
|
input: { sessionID: string },
|
||||||
|
output: { context: string[] },
|
||||||
|
) => Promise<void>
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
return async (
|
||||||
|
input: { sessionID: string },
|
||||||
|
output: { context: string[] },
|
||||||
|
): Promise<void> => {
|
||||||
|
await hooks.compactionContextInjector?.capture(input.sessionID)
|
||||||
|
await hooks.compactionTodoPreserver?.capture(input.sessionID)
|
||||||
|
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if (hooks.compactionContextInjector) {
|
||||||
|
output.context.push(hooks.compactionContextInjector.inject(input.sessionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("experimental.session.compacting handler", () => {
|
||||||
|
//#given all three hooks are present
|
||||||
|
//#when compacting handler is invoked
|
||||||
|
//#then all hooks are called in order: capture → PreCompact → contextInjector
|
||||||
|
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
|
||||||
|
const callOrder: string[] = []
|
||||||
|
|
||||||
|
const handler = createCompactingHandler({
|
||||||
|
compactionContextInjector: {
|
||||||
|
capture: mock(async () => {
|
||||||
|
callOrder.push("checkpointCapture")
|
||||||
|
}),
|
||||||
|
inject: mock((sessionID: string) => {
|
||||||
|
callOrder.push("contextInjector")
|
||||||
|
return `context-for-${sessionID}`
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
compactionTodoPreserver: {
|
||||||
|
capture: mock(async () => {
|
||||||
|
callOrder.push("capture")
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
claudeCodeHooks: {
|
||||||
|
"experimental.session.compacting": mock(async () => {
|
||||||
|
callOrder.push("preCompact")
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = { context: [] as string[] }
|
||||||
|
await handler({ sessionID: "ses_test" }, output)
|
||||||
|
|
||||||
|
expect(callOrder).toEqual([
|
||||||
|
"checkpointCapture",
|
||||||
|
"capture",
|
||||||
|
"preCompact",
|
||||||
|
"contextInjector",
|
||||||
|
])
|
||||||
|
expect(output.context).toEqual(["context-for-ses_test"])
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given claudeCodeHooks injects context during PreCompact
|
||||||
|
//#when compacting handler is invoked
|
||||||
|
//#then injected context from PreCompact is preserved in output
|
||||||
|
it("preserves context injected by PreCompact hooks", async () => {
|
||||||
|
const handler = createCompactingHandler({
|
||||||
|
claudeCodeHooks: {
|
||||||
|
"experimental.session.compacting": async (_input, output) => {
|
||||||
|
output.context.push("precompact-injected-context")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = { context: [] as string[] }
|
||||||
|
await handler({ sessionID: "ses_test" }, output)
|
||||||
|
|
||||||
|
expect(output.context).toContain("precompact-injected-context")
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given claudeCodeHooks is null (no claude code hooks configured)
|
||||||
|
//#when compacting handler is invoked
|
||||||
|
//#then handler completes without error and other hooks still run
|
||||||
|
it("handles null claudeCodeHooks gracefully", async () => {
|
||||||
|
const captureMock = mock(async () => {})
|
||||||
|
const checkpointCaptureMock = mock(async () => {})
|
||||||
|
const contextMock = mock(() => "injected-context")
|
||||||
|
|
||||||
|
const handler = createCompactingHandler({
|
||||||
|
compactionContextInjector: {
|
||||||
|
capture: checkpointCaptureMock,
|
||||||
|
inject: contextMock,
|
||||||
|
},
|
||||||
|
compactionTodoPreserver: { capture: captureMock },
|
||||||
|
claudeCodeHooks: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = { context: [] as string[] }
|
||||||
|
await handler({ sessionID: "ses_test" }, output)
|
||||||
|
|
||||||
|
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ses_test")
|
||||||
|
expect(contextMock).toHaveBeenCalledWith("ses_test")
|
||||||
|
expect(output.context).toEqual(["injected-context"])
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given compactionContextInjector is null
|
||||||
|
//#when compacting handler is invoked
|
||||||
|
//#then handler does not early-return, PreCompact hooks still execute
|
||||||
|
it("does not early-return when compactionContextInjector is null", async () => {
|
||||||
|
const preCompactMock = mock(async () => {})
|
||||||
|
|
||||||
|
const handler = createCompactingHandler({
|
||||||
|
claudeCodeHooks: {
|
||||||
|
"experimental.session.compacting": preCompactMock,
|
||||||
|
},
|
||||||
|
compactionContextInjector: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = { context: [] as string[] }
|
||||||
|
await handler({ sessionID: "ses_test" }, output)
|
||||||
|
|
||||||
|
expect(preCompactMock).toHaveBeenCalled()
|
||||||
|
expect(output.context).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
|
||||||
|
describe("look_at tool conditional registration", () => {
|
||||||
|
describe("isMultimodalLookerEnabled logic", () => {
|
||||||
|
// given multimodal-looker is in disabled_agents
|
||||||
|
// when checking if agent is enabled
|
||||||
|
// then should return false (disabled)
|
||||||
|
it("returns false when multimodal-looker is disabled (exact case)", () => {
|
||||||
|
const disabledAgents: string[] = ["multimodal-looker"]
|
||||||
|
const isEnabled = !disabledAgents.some(
|
||||||
|
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||||
|
)
|
||||||
|
expect(isEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// given multimodal-looker is in disabled_agents with different case
|
||||||
|
// when checking if agent is enabled
|
||||||
|
// then should return false (case-insensitive match)
|
||||||
|
it("returns false when multimodal-looker is disabled (case-insensitive)", () => {
|
||||||
|
const disabledAgents: string[] = ["Multimodal-Looker"]
|
||||||
|
const isEnabled = !disabledAgents.some(
|
||||||
|
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||||
|
)
|
||||||
|
expect(isEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// given multimodal-looker is NOT in disabled_agents
|
||||||
|
// when checking if agent is enabled
|
||||||
|
// then should return true (enabled)
|
||||||
|
it("returns true when multimodal-looker is not disabled", () => {
|
||||||
|
const disabledAgents: string[] = ["oracle", "librarian"]
|
||||||
|
const isEnabled = !disabledAgents.some(
|
||||||
|
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||||
|
)
|
||||||
|
expect(isEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// given disabled_agents is empty
|
||||||
|
// when checking if agent is enabled
|
||||||
|
// then should return true (enabled by default)
|
||||||
|
it("returns true when disabled_agents is empty", () => {
|
||||||
|
const disabledAgents: string[] = []
|
||||||
|
const isEnabled = !disabledAgents.some(
|
||||||
|
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||||
|
)
|
||||||
|
expect(isEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// given disabled_agents is undefined (simulated as empty array)
|
||||||
|
// when checking if agent is enabled
|
||||||
|
// then should return true (enabled by default)
|
||||||
|
it("returns true when disabled_agents is undefined (fallback to empty)", () => {
|
||||||
|
const disabledAgents: string[] | undefined = undefined
|
||||||
|
const list: string[] = disabledAgents ?? []
|
||||||
|
const isEnabled = !list.some(
|
||||||
|
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||||
|
)
|
||||||
|
expect(isEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("conditional tool spread pattern", () => {
|
||||||
|
// given lookAt is not null (agent enabled)
|
||||||
|
// when spreading into tool object
|
||||||
|
// then look_at should be included
|
||||||
|
it("includes look_at when lookAt is not null", () => {
|
||||||
|
const lookAt = { execute: () => {} }
|
||||||
|
const tools = {
|
||||||
|
...(lookAt ? { look_at: lookAt } : {}),
|
||||||
|
}
|
||||||
|
expect(tools).toHaveProperty("look_at")
|
||||||
|
})
|
||||||
|
|
||||||
|
// given lookAt is null (agent disabled)
|
||||||
|
// when spreading into tool object
|
||||||
|
// then look_at should NOT be included
|
||||||
|
it("excludes look_at when lookAt is null", () => {
|
||||||
|
const lookAt = null
|
||||||
|
const tools = {
|
||||||
|
...(lookAt ? { look_at: lookAt } : {}),
|
||||||
|
}
|
||||||
|
expect(tools).not.toHaveProperty("look_at")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,7 +29,6 @@ const mockCreateHooks = mock(() => ({
|
|||||||
compactionTodoPreserver: undefined,
|
compactionTodoPreserver: undefined,
|
||||||
claudeCodeHooks: undefined,
|
claudeCodeHooks: undefined,
|
||||||
}))
|
}))
|
||||||
const mockCreatePluginDispose = mock(() => async () => {})
|
|
||||||
const mockCreatePluginInterface = mock(() => ({}))
|
const mockCreatePluginInterface = mock(() => ({}))
|
||||||
const mockCreatePluginPostHog = mock(() => ({
|
const mockCreatePluginPostHog = mock(() => ({
|
||||||
trackActive: () => {
|
trackActive: () => {
|
||||||
@@ -70,9 +69,6 @@ function installModuleMocks(): void {
|
|||||||
mock.module("./create-hooks", () => ({
|
mock.module("./create-hooks", () => ({
|
||||||
createHooks: mockCreateHooks,
|
createHooks: mockCreateHooks,
|
||||||
}))
|
}))
|
||||||
mock.module("./plugin-dispose", () => ({
|
|
||||||
createPluginDispose: mockCreatePluginDispose,
|
|
||||||
}))
|
|
||||||
mock.module("./plugin-interface", () => ({
|
mock.module("./plugin-interface", () => ({
|
||||||
createPluginInterface: mockCreatePluginInterface,
|
createPluginInterface: mockCreatePluginInterface,
|
||||||
}))
|
}))
|
||||||
@@ -110,7 +106,7 @@ function installModuleMocks(): void {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("OhMyOpenCodePlugin telemetry isolation", () => {
|
describe("oh-my-openagent telemetry isolation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
installModuleMocks()
|
installModuleMocks()
|
||||||
@@ -125,12 +121,13 @@ describe("OhMyOpenCodePlugin telemetry isolation", () => {
|
|||||||
const { default: plugin } = await import(`./index?telemetry=${Date.now()}-${Math.random()}`)
|
const { default: plugin } = await import(`./index?telemetry=${Date.now()}-${Math.random()}`)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await plugin({
|
const result = await plugin.server({
|
||||||
directory: "/tmp/project",
|
directory: "/tmp/project",
|
||||||
client: {},
|
client: {},
|
||||||
} as Parameters<typeof plugin>[0])
|
} as Parameters<typeof plugin.server>[0])
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toMatchObject({ name: "oh-my-openagent" })
|
expect(typeof result).toBe("object")
|
||||||
|
expect(result).not.toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+16
-231
@@ -1,223 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
describe("experimental.session.compacting handler", () => {
|
|
||||||
function createCompactingHandler(hooks: {
|
|
||||||
compactionContextInjector?: {
|
|
||||||
capture: (sessionID: string) => Promise<void>
|
|
||||||
inject: (sessionID: string) => string
|
|
||||||
}
|
|
||||||
compactionTodoPreserver?: { capture: (sessionID: string) => Promise<void> }
|
|
||||||
claudeCodeHooks?: {
|
|
||||||
"experimental.session.compacting"?: (
|
|
||||||
input: { sessionID: string },
|
|
||||||
output: { context: string[] },
|
|
||||||
) => Promise<void>
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
return async (
|
|
||||||
_input: { sessionID: string },
|
|
||||||
output: { context: string[] },
|
|
||||||
): Promise<void> => {
|
|
||||||
await hooks.compactionContextInjector?.capture(_input.sessionID)
|
|
||||||
await hooks.compactionTodoPreserver?.capture(_input.sessionID)
|
|
||||||
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
|
|
||||||
_input,
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
if (hooks.compactionContextInjector) {
|
|
||||||
output.context.push(hooks.compactionContextInjector.inject(_input.sessionID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#given all three hooks are present
|
|
||||||
//#when compacting handler is invoked
|
|
||||||
//#then all hooks are called in order: capture → PreCompact → contextInjector
|
|
||||||
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
|
|
||||||
const callOrder: string[] = []
|
|
||||||
|
|
||||||
const handler = createCompactingHandler({
|
|
||||||
compactionContextInjector: {
|
|
||||||
capture: mock(async () => {
|
|
||||||
callOrder.push("checkpointCapture")
|
|
||||||
}),
|
|
||||||
inject: mock((sessionID: string) => {
|
|
||||||
callOrder.push("contextInjector")
|
|
||||||
return `context-for-${sessionID}`
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
compactionTodoPreserver: {
|
|
||||||
capture: mock(async () => { callOrder.push("capture") }),
|
|
||||||
},
|
|
||||||
claudeCodeHooks: {
|
|
||||||
"experimental.session.compacting": mock(async () => {
|
|
||||||
callOrder.push("preCompact")
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = { context: [] as string[] }
|
|
||||||
await handler({ sessionID: "ses_test" }, output)
|
|
||||||
|
|
||||||
expect(callOrder).toEqual(["checkpointCapture", "capture", "preCompact", "contextInjector"])
|
|
||||||
expect(output.context).toEqual(["context-for-ses_test"])
|
|
||||||
})
|
|
||||||
|
|
||||||
//#given claudeCodeHooks injects context during PreCompact
|
|
||||||
//#when compacting handler is invoked
|
|
||||||
//#then injected context from PreCompact is preserved in output
|
|
||||||
it("preserves context injected by PreCompact hooks", async () => {
|
|
||||||
const handler = createCompactingHandler({
|
|
||||||
claudeCodeHooks: {
|
|
||||||
"experimental.session.compacting": async (_input, output) => {
|
|
||||||
output.context.push("precompact-injected-context")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = { context: [] as string[] }
|
|
||||||
await handler({ sessionID: "ses_test" }, output)
|
|
||||||
|
|
||||||
expect(output.context).toContain("precompact-injected-context")
|
|
||||||
})
|
|
||||||
|
|
||||||
//#given claudeCodeHooks is null (no claude code hooks configured)
|
|
||||||
//#when compacting handler is invoked
|
|
||||||
//#then handler completes without error and other hooks still run
|
|
||||||
it("handles null claudeCodeHooks gracefully", async () => {
|
|
||||||
const captureMock = mock(async () => {})
|
|
||||||
const checkpointCaptureMock = mock(async () => {})
|
|
||||||
const contextMock = mock(() => "injected-context")
|
|
||||||
|
|
||||||
const handler = createCompactingHandler({
|
|
||||||
compactionContextInjector: {
|
|
||||||
capture: checkpointCaptureMock,
|
|
||||||
inject: contextMock,
|
|
||||||
},
|
|
||||||
compactionTodoPreserver: { capture: captureMock },
|
|
||||||
claudeCodeHooks: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = { context: [] as string[] }
|
|
||||||
await handler({ sessionID: "ses_test" }, output)
|
|
||||||
|
|
||||||
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
|
|
||||||
expect(captureMock).toHaveBeenCalledWith("ses_test")
|
|
||||||
expect(contextMock).toHaveBeenCalledWith("ses_test")
|
|
||||||
expect(output.context).toEqual(["injected-context"])
|
|
||||||
})
|
|
||||||
|
|
||||||
//#given compactionContextInjector is null
|
|
||||||
//#when compacting handler is invoked
|
|
||||||
//#then handler does not early-return, PreCompact hooks still execute
|
|
||||||
it("does not early-return when compactionContextInjector is null", async () => {
|
|
||||||
const preCompactMock = mock(async () => {})
|
|
||||||
|
|
||||||
const handler = createCompactingHandler({
|
|
||||||
claudeCodeHooks: {
|
|
||||||
"experimental.session.compacting": preCompactMock,
|
|
||||||
},
|
|
||||||
compactionContextInjector: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = { context: [] as string[] }
|
|
||||||
await handler({ sessionID: "ses_test" }, output)
|
|
||||||
|
|
||||||
expect(preCompactMock).toHaveBeenCalled()
|
|
||||||
expect(output.context).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for conditional tool registration logic in index.ts
|
|
||||||
*
|
|
||||||
* The actual plugin initialization is complex to test directly,
|
|
||||||
* so we test the underlying logic that determines tool registration.
|
|
||||||
*/
|
|
||||||
describe("look_at tool conditional registration", () => {
|
|
||||||
describe("isMultimodalLookerEnabled logic", () => {
|
|
||||||
// given multimodal-looker is in disabled_agents
|
|
||||||
// when checking if agent is enabled
|
|
||||||
// then should return false (disabled)
|
|
||||||
it("returns false when multimodal-looker is disabled (exact case)", () => {
|
|
||||||
const disabledAgents: string[] = ["multimodal-looker"]
|
|
||||||
const isEnabled = !disabledAgents.some(
|
|
||||||
(agent) => agent.toLowerCase() === "multimodal-looker"
|
|
||||||
)
|
|
||||||
expect(isEnabled).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// given multimodal-looker is in disabled_agents with different case
|
|
||||||
// when checking if agent is enabled
|
|
||||||
// then should return false (case-insensitive match)
|
|
||||||
it("returns false when multimodal-looker is disabled (case-insensitive)", () => {
|
|
||||||
const disabledAgents: string[] = ["Multimodal-Looker"]
|
|
||||||
const isEnabled = !disabledAgents.some(
|
|
||||||
(agent) => agent.toLowerCase() === "multimodal-looker"
|
|
||||||
)
|
|
||||||
expect(isEnabled).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// given multimodal-looker is NOT in disabled_agents
|
|
||||||
// when checking if agent is enabled
|
|
||||||
// then should return true (enabled)
|
|
||||||
it("returns true when multimodal-looker is not disabled", () => {
|
|
||||||
const disabledAgents: string[] = ["oracle", "librarian"]
|
|
||||||
const isEnabled = !disabledAgents.some(
|
|
||||||
(agent) => agent.toLowerCase() === "multimodal-looker"
|
|
||||||
)
|
|
||||||
expect(isEnabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// given disabled_agents is empty
|
|
||||||
// when checking if agent is enabled
|
|
||||||
// then should return true (enabled by default)
|
|
||||||
it("returns true when disabled_agents is empty", () => {
|
|
||||||
const disabledAgents: string[] = []
|
|
||||||
const isEnabled = !disabledAgents.some(
|
|
||||||
(agent) => agent.toLowerCase() === "multimodal-looker"
|
|
||||||
)
|
|
||||||
expect(isEnabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// given disabled_agents is undefined (simulated as empty array)
|
|
||||||
// when checking if agent is enabled
|
|
||||||
// then should return true (enabled by default)
|
|
||||||
it("returns true when disabled_agents is undefined (fallback to empty)", () => {
|
|
||||||
const disabledAgents: string[] | undefined = undefined
|
|
||||||
const list: string[] = disabledAgents ?? []
|
|
||||||
const isEnabled = !list.some(
|
|
||||||
(agent) => agent.toLowerCase() === "multimodal-looker"
|
|
||||||
)
|
|
||||||
expect(isEnabled).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("conditional tool spread pattern", () => {
|
|
||||||
// given lookAt is not null (agent enabled)
|
|
||||||
// when spreading into tool object
|
|
||||||
// then look_at should be included
|
|
||||||
it("includes look_at when lookAt is not null", () => {
|
|
||||||
const lookAt = { execute: () => {} } // mock tool
|
|
||||||
const tools = {
|
|
||||||
...(lookAt ? { look_at: lookAt } : {}),
|
|
||||||
}
|
|
||||||
expect(tools).toHaveProperty("look_at")
|
|
||||||
})
|
|
||||||
|
|
||||||
// given lookAt is null (agent disabled)
|
|
||||||
// when spreading into tool object
|
|
||||||
// then look_at should NOT be included
|
|
||||||
it("excludes look_at when lookAt is null", () => {
|
|
||||||
const lookAt = null
|
|
||||||
const tools = {
|
|
||||||
...(lookAt ? { look_at: lookAt } : {}),
|
|
||||||
}
|
|
||||||
expect(tools).not.toHaveProperty("look_at")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const mockInitConfigContext = mock(() => {})
|
const mockInitConfigContext = mock(() => {})
|
||||||
const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null }))
|
const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null }))
|
||||||
const mockGetSkillPluginConflictWarning = mock(() => "")
|
const mockGetSkillPluginConflictWarning = mock(() => "")
|
||||||
@@ -252,12 +34,11 @@ const mockCreateHooks = mock(() => ({
|
|||||||
compactionTodoPreserver: undefined,
|
compactionTodoPreserver: undefined,
|
||||||
claudeCodeHooks: undefined,
|
claudeCodeHooks: undefined,
|
||||||
}))
|
}))
|
||||||
const mockCreatePluginDispose = mock(() => async () => {})
|
|
||||||
const mockCreatePluginInterface = mock(() => ({}))
|
const mockCreatePluginInterface = mock(() => ({}))
|
||||||
const mockInitializeOpenClaw = mock(async () => {})
|
const mockInitializeOpenClaw = mock(async () => {})
|
||||||
const mockStartTmuxCheck = mock(() => {})
|
const mockStartTmuxCheck = mock(() => {})
|
||||||
|
|
||||||
let OhMyOpenCodePlugin: (typeof import("./index"))["default"]
|
let pluginModule: (typeof import("./index"))["default"]
|
||||||
|
|
||||||
function installIndexModuleMocks(): void {
|
function installIndexModuleMocks(): void {
|
||||||
mock.module("./cli/config-manager/config-context", () => ({
|
mock.module("./cli/config-manager/config-context", () => ({
|
||||||
@@ -297,10 +78,6 @@ function installIndexModuleMocks(): void {
|
|||||||
createHooks: mockCreateHooks,
|
createHooks: mockCreateHooks,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("./plugin-dispose", () => ({
|
|
||||||
createPluginDispose: mockCreatePluginDispose,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("./plugin-interface", () => ({
|
mock.module("./plugin-interface", () => ({
|
||||||
createPluginInterface: mockCreatePluginInterface,
|
createPluginInterface: mockCreatePluginInterface,
|
||||||
}))
|
}))
|
||||||
@@ -333,11 +110,11 @@ async function importFreshIndexModule(): Promise<typeof import("./index")> {
|
|||||||
return import(`./index?test=${Date.now()}-${Math.random()}`)
|
return import(`./index?test=${Date.now()}-${Math.random()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("OhMyOpenCodePlugin", () => {
|
describe("oh-my-openagent plugin module", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
installIndexModuleMocks()
|
installIndexModuleMocks()
|
||||||
;({ default: OhMyOpenCodePlugin } = await importFreshIndexModule())
|
;({ default: pluginModule } = await importFreshIndexModule())
|
||||||
mockInitConfigContext.mockClear()
|
mockInitConfigContext.mockClear()
|
||||||
mockDetectExternalSkillPlugin.mockClear()
|
mockDetectExternalSkillPlugin.mockClear()
|
||||||
mockGetSkillPluginConflictWarning.mockClear()
|
mockGetSkillPluginConflictWarning.mockClear()
|
||||||
@@ -350,7 +127,6 @@ describe("OhMyOpenCodePlugin", () => {
|
|||||||
mockCreateManagers.mockClear()
|
mockCreateManagers.mockClear()
|
||||||
mockCreateTools.mockClear()
|
mockCreateTools.mockClear()
|
||||||
mockCreateHooks.mockClear()
|
mockCreateHooks.mockClear()
|
||||||
mockCreatePluginDispose.mockClear()
|
|
||||||
mockCreatePluginInterface.mockClear()
|
mockCreatePluginInterface.mockClear()
|
||||||
mockInitializeOpenClaw.mockClear()
|
mockInitializeOpenClaw.mockClear()
|
||||||
mockStartTmuxCheck.mockClear()
|
mockStartTmuxCheck.mockClear()
|
||||||
@@ -375,10 +151,10 @@ describe("OhMyOpenCodePlugin", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await OhMyOpenCodePlugin({
|
await pluginModule.server({
|
||||||
directory: "/tmp/project",
|
directory: "/tmp/project",
|
||||||
client: {},
|
client: {},
|
||||||
} as Parameters<typeof OhMyOpenCodePlugin>[0])
|
} as Parameters<typeof pluginModule.server>[0])
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(mockInitializeOpenClaw).toHaveBeenCalledTimes(1)
|
expect(mockInitializeOpenClaw).toHaveBeenCalledTimes(1)
|
||||||
@@ -390,12 +166,21 @@ describe("OhMyOpenCodePlugin", () => {
|
|||||||
mockLoadPluginConfig.mockReturnValue({})
|
mockLoadPluginConfig.mockReturnValue({})
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await OhMyOpenCodePlugin({
|
await pluginModule.server({
|
||||||
directory: "/tmp/project",
|
directory: "/tmp/project",
|
||||||
client: {},
|
client: {},
|
||||||
} as Parameters<typeof OhMyOpenCodePlugin>[0])
|
} as Parameters<typeof pluginModule.server>[0])
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(mockInitializeOpenClaw).not.toHaveBeenCalled()
|
expect(mockInitializeOpenClaw).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("exports a V1 PluginModule shape with id and server", () => {
|
||||||
|
// given the plugin module is loaded
|
||||||
|
// when inspecting the default export
|
||||||
|
// then it has the expected V1 shape
|
||||||
|
expect(typeof pluginModule).toBe("object")
|
||||||
|
expect(pluginModule.id).toBe("oh-my-openagent")
|
||||||
|
expect(typeof pluginModule.server).toBe("function")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-32
@@ -1,5 +1,5 @@
|
|||||||
import { initConfigContext } from "./cli/config-manager/config-context"
|
import { initConfigContext } from "./cli/config-manager/config-context"
|
||||||
import type { Plugin } from "@opencode-ai/plugin"
|
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import type { HookName } from "./config"
|
import type { HookName } from "./config"
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runt
|
|||||||
import { createTools } from "./create-tools"
|
import { createTools } from "./create-tools"
|
||||||
import { initializeOpenClaw } from "./openclaw"
|
import { initializeOpenClaw } from "./openclaw"
|
||||||
import { createPluginInterface } from "./plugin-interface"
|
import { createPluginInterface } from "./plugin-interface"
|
||||||
import { createPluginDispose, type PluginDispose } from "./plugin-dispose"
|
|
||||||
|
|
||||||
import { loadPluginConfig } from "./plugin-config"
|
import { loadPluginConfig } from "./plugin-config"
|
||||||
import { createModelCacheState } from "./plugin-state"
|
import { createModelCacheState } from "./plugin-state"
|
||||||
@@ -17,27 +16,23 @@ import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
|||||||
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
|
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
|
||||||
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
|
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
|
||||||
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
|
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
|
||||||
import { lspManager } from "./tools/lsp/client"
|
|
||||||
import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog"
|
import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog"
|
||||||
|
|
||||||
let activePluginDispose: PluginDispose | null = null
|
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||||
|
|
||||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|
||||||
initConfigContext("opencode", null)
|
initConfigContext("opencode", null)
|
||||||
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", {
|
log("[oh-my-openagent] ENTRY - plugin loading", {
|
||||||
directory: ctx.directory,
|
directory: input.directory,
|
||||||
})
|
})
|
||||||
logLegacyPluginStartupWarning()
|
logLegacyPluginStartupWarning()
|
||||||
|
|
||||||
const skillPluginCheck = detectExternalSkillPlugin(ctx.directory)
|
const skillPluginCheck = detectExternalSkillPlugin(input.directory)
|
||||||
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
|
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
|
||||||
console.warn(getSkillPluginConflictWarning(skillPluginCheck.pluginName))
|
console.warn(getSkillPluginConflictWarning(skillPluginCheck.pluginName))
|
||||||
}
|
}
|
||||||
|
|
||||||
injectServerAuthIntoClient(ctx.client)
|
injectServerAuthIntoClient(input.client)
|
||||||
await activePluginDispose?.()
|
|
||||||
|
|
||||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx)
|
const pluginConfig = loadPluginConfig(input.directory, input)
|
||||||
|
|
||||||
const posthog = createPluginPostHog()
|
const posthog = createPluginPostHog()
|
||||||
const distinctId = getPostHogDistinctId()
|
const distinctId = getPostHogDistinctId()
|
||||||
@@ -78,7 +73,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
const modelCacheState = createModelCacheState()
|
const modelCacheState = createModelCacheState()
|
||||||
|
|
||||||
const managers = createManagers({
|
const managers = createManagers({
|
||||||
ctx,
|
ctx: input,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
tmuxConfig,
|
tmuxConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
@@ -86,31 +81,25 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toolsResult = await createTools({
|
const toolsResult = await createTools({
|
||||||
ctx,
|
ctx: input,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
managers,
|
managers,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hooks = createHooks({
|
const hooks = createHooks({
|
||||||
ctx,
|
ctx: input,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
backgroundManager: managers.backgroundManager,
|
backgroundManager: managers.backgroundManager,
|
||||||
|
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
mergedSkills: toolsResult.mergedSkills,
|
mergedSkills: toolsResult.mergedSkills,
|
||||||
availableSkills: toolsResult.availableSkills,
|
availableSkills: toolsResult.availableSkills,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager: managers.backgroundManager,
|
|
||||||
skillMcpManager: managers.skillMcpManager,
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: hooks.disposeHooks,
|
|
||||||
})
|
|
||||||
|
|
||||||
const pluginInterface = createPluginInterface({
|
const pluginInterface = createPluginInterface({
|
||||||
ctx,
|
ctx: input,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
firstMessageVariantGate,
|
firstMessageVariantGate,
|
||||||
managers,
|
managers,
|
||||||
@@ -118,30 +107,32 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
tools: toolsResult.filteredTools,
|
tools: toolsResult.filteredTools,
|
||||||
})
|
})
|
||||||
|
|
||||||
activePluginDispose = dispose
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "oh-my-openagent",
|
|
||||||
...pluginInterface,
|
...pluginInterface,
|
||||||
|
|
||||||
"experimental.session.compacting": async (
|
"experimental.session.compacting": async (
|
||||||
_input: { sessionID: string },
|
compactingInput: { sessionID: string },
|
||||||
output: { context: string[] },
|
output: { context: string[] },
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await hooks.compactionContextInjector?.capture(_input.sessionID)
|
await hooks.compactionContextInjector?.capture(compactingInput.sessionID)
|
||||||
await hooks.compactionTodoPreserver?.capture(_input.sessionID)
|
await hooks.compactionTodoPreserver?.capture(compactingInput.sessionID)
|
||||||
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
|
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
|
||||||
_input,
|
compactingInput,
|
||||||
output,
|
output,
|
||||||
)
|
)
|
||||||
if (hooks.compactionContextInjector) {
|
if (hooks.compactionContextInjector) {
|
||||||
output.context.push(hooks.compactionContextInjector.inject(_input.sessionID))
|
output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OhMyOpenCodePlugin
|
const pluginModule: PluginModule = {
|
||||||
|
id: "oh-my-openagent",
|
||||||
|
server: serverPlugin,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pluginModule
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
OhMyOpenCodeConfig,
|
OhMyOpenCodeConfig,
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
import { describe, expect, spyOn, test } from "bun:test"
|
|
||||||
|
|
||||||
import { disposeCreatedHooks } from "./create-hooks"
|
|
||||||
import { createPluginDispose } from "./plugin-dispose"
|
|
||||||
|
|
||||||
describe("createPluginDispose", () => {
|
|
||||||
test("#given plugin with active managers and hooks #when dispose() is called #then backgroundManager.shutdown() is called", async () => {
|
|
||||||
// given
|
|
||||||
const backgroundManager = {
|
|
||||||
shutdown: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const skillMcpManager = {
|
|
||||||
disconnectAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const shutdownSpy = spyOn(backgroundManager, "shutdown")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager,
|
|
||||||
skillMcpManager,
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: (): void => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(shutdownSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given plugin with active MCP connections #when dispose() is called #then skillMcpManager.disconnectAll() is called", async () => {
|
|
||||||
// given
|
|
||||||
const backgroundManager = {
|
|
||||||
shutdown: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const skillMcpManager = {
|
|
||||||
disconnectAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager,
|
|
||||||
skillMcpManager,
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: (): void => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given plugin with hooks that have dispose #when dispose() is called #then each hook's dispose is called", async () => {
|
|
||||||
// given
|
|
||||||
const claudeCodeHooks = {
|
|
||||||
dispose: (): void => {},
|
|
||||||
}
|
|
||||||
const commentChecker = {
|
|
||||||
dispose: (): void => {},
|
|
||||||
}
|
|
||||||
const runtimeFallback = {
|
|
||||||
dispose: (): void => {},
|
|
||||||
}
|
|
||||||
const todoContinuationEnforcer = {
|
|
||||||
dispose: (): void => {},
|
|
||||||
}
|
|
||||||
const autoSlashCommand = {
|
|
||||||
dispose: (): void => {},
|
|
||||||
}
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const claudeCodeHooksDisposeSpy = spyOn(claudeCodeHooks, "dispose")
|
|
||||||
const commentCheckerDisposeSpy = spyOn(commentChecker, "dispose")
|
|
||||||
const runtimeFallbackDisposeSpy = spyOn(runtimeFallback, "dispose")
|
|
||||||
const todoContinuationEnforcerDisposeSpy = spyOn(todoContinuationEnforcer, "dispose")
|
|
||||||
const autoSlashCommandDisposeSpy = spyOn(autoSlashCommand, "dispose")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager: {
|
|
||||||
shutdown: async (): Promise<void> => {},
|
|
||||||
},
|
|
||||||
skillMcpManager: {
|
|
||||||
disconnectAll: async (): Promise<void> => {},
|
|
||||||
},
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: (): void => {
|
|
||||||
disposeCreatedHooks({
|
|
||||||
claudeCodeHooks,
|
|
||||||
commentChecker,
|
|
||||||
runtimeFallback,
|
|
||||||
todoContinuationEnforcer,
|
|
||||||
autoSlashCommand,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(claudeCodeHooksDisposeSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(commentCheckerDisposeSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(runtimeFallbackDisposeSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(todoContinuationEnforcerDisposeSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(autoSlashCommandDisposeSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given dispose already called #when dispose() called again #then no errors", async () => {
|
|
||||||
// given
|
|
||||||
const backgroundManager = {
|
|
||||||
shutdown: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const skillMcpManager = {
|
|
||||||
disconnectAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const disposeHooks = {
|
|
||||||
run: (): void => {},
|
|
||||||
}
|
|
||||||
const shutdownSpy = spyOn(backgroundManager, "shutdown")
|
|
||||||
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
|
|
||||||
const stopAllSpy = spyOn(lspManager, "stopAll")
|
|
||||||
const disposeHooksSpy = spyOn(disposeHooks, "run")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager,
|
|
||||||
skillMcpManager,
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: disposeHooks.run,
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(shutdownSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(stopAllSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(disposeHooksSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given backgroundManager.shutdown() throws #when dispose() is called #then skillMcpManager.disconnectAll() and disposeHooks() are still called", async () => {
|
|
||||||
// given
|
|
||||||
const backgroundManager = {
|
|
||||||
shutdown: async (): Promise<void> => {
|
|
||||||
throw new Error("shutdown failed")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const skillMcpManager = {
|
|
||||||
disconnectAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const disposeHooksCalls: number[] = []
|
|
||||||
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager,
|
|
||||||
skillMcpManager,
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: (): void => {
|
|
||||||
disposeHooksCalls.push(1)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(disposeHooksCalls).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given skillMcpManager.disconnectAll() throws #when dispose() is called #then disposeHooks() is still called", async () => {
|
|
||||||
// given
|
|
||||||
const backgroundManager = {
|
|
||||||
shutdown: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const skillMcpManager = {
|
|
||||||
disconnectAll: async (): Promise<void> => {
|
|
||||||
throw new Error("disconnectAll failed")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const disposeHooksCalls: number[] = []
|
|
||||||
const shutdownSpy = spyOn(backgroundManager, "shutdown")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager,
|
|
||||||
skillMcpManager,
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: (): void => {
|
|
||||||
disposeHooksCalls.push(1)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(shutdownSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(disposeHooksCalls).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given active LSP clients #when dispose runs #then lsp manager is stopped", async () => {
|
|
||||||
// given
|
|
||||||
const lspManager = {
|
|
||||||
stopAll: async (): Promise<void> => {},
|
|
||||||
}
|
|
||||||
const stopAllSpy = spyOn(lspManager, "stopAll")
|
|
||||||
const dispose = createPluginDispose({
|
|
||||||
backgroundManager: {
|
|
||||||
shutdown: async (): Promise<void> => {},
|
|
||||||
},
|
|
||||||
skillMcpManager: {
|
|
||||||
disconnectAll: async (): Promise<void> => {},
|
|
||||||
},
|
|
||||||
lspManager,
|
|
||||||
disposeHooks: (): void => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await dispose()
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(stopAllSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { log } from "./shared"
|
|
||||||
|
|
||||||
export type PluginDispose = () => Promise<void>
|
|
||||||
|
|
||||||
export function createPluginDispose(args: {
|
|
||||||
backgroundManager: {
|
|
||||||
shutdown: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
skillMcpManager: {
|
|
||||||
disconnectAll: () => Promise<void>
|
|
||||||
}
|
|
||||||
lspManager: {
|
|
||||||
stopAll: () => Promise<void>
|
|
||||||
}
|
|
||||||
disposeHooks: () => void
|
|
||||||
}): PluginDispose {
|
|
||||||
const { backgroundManager, skillMcpManager, lspManager, disposeHooks } = args
|
|
||||||
let disposePromise: Promise<void> | null = null
|
|
||||||
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
if (disposePromise) {
|
|
||||||
await disposePromise
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
disposePromise = (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await backgroundManager.shutdown()
|
|
||||||
} catch (error) {
|
|
||||||
log("[plugin-dispose] backgroundManager.shutdown() error:", error)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await skillMcpManager.disconnectAll()
|
|
||||||
} catch (error) {
|
|
||||||
log("[plugin-dispose] skillMcpManager.disconnectAll() error:", error)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await lspManager.stopAll()
|
|
||||||
} catch (error) {
|
|
||||||
log("[plugin-dispose] lspManager.stopAll() error:", error)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
disposeHooks()
|
|
||||||
} catch (error) {
|
|
||||||
log("[plugin-dispose] disposeHooks() error:", error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
await disposePromise
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,13 +65,13 @@ function createChatMessageHandlerHooks(modelFallback: ReturnType<typeof createMo
|
|||||||
let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined
|
let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined
|
||||||
let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined
|
let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
readConnectedProvidersCacheSpy?.mockRestore()
|
readConnectedProvidersCacheSpy?.mockRestore()
|
||||||
readProviderModelsCacheSpy?.mockRestore()
|
readProviderModelsCacheSpy?.mockRestore()
|
||||||
readConnectedProvidersCacheSpy = undefined
|
readConnectedProvidersCacheSpy = undefined
|
||||||
readProviderModelsCacheSpy = undefined
|
readProviderModelsCacheSpy = undefined
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createEventHandler - category runtime fallback suppression", () => {
|
describe("createEventHandler - category runtime fallback suppression", () => {
|
||||||
test("does not arm retry fallback when category session explicitly stores no fallback chain [regression #2941]", async () => {
|
test("does not arm retry fallback when category session explicitly stores no fallback chain [regression #2941]", async () => {
|
||||||
@@ -83,11 +83,10 @@ describe("createEventHandler - category runtime fallback suppression", () => {
|
|||||||
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||||
|
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
setSessionAgent(sessionID, "sisyphus-junior")
|
|
||||||
setSessionFallbackChain(sessionID, undefined)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
setSessionAgent(sessionID, "sisyphus-junior")
|
||||||
|
setSessionFallbackChain(modelFallback, sessionID, undefined)
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({
|
ctx: asEventHandlerContext({
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
|
|||||||
@@ -142,9 +142,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_fallback"
|
const sessionID = "ses_status_retry_fallback"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||||
|
|
||||||
@@ -232,8 +231,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_dedup"
|
const sessionID = "ses_status_retry_dedup"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
@@ -293,8 +292,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_runtime_enabled"
|
const sessionID = "ses_status_retry_runtime_enabled"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
const runtimeFallback = {
|
const runtimeFallback = {
|
||||||
event: async () => {},
|
event: async () => {},
|
||||||
"chat.message": async () => {},
|
"chat.message": async () => {},
|
||||||
@@ -346,9 +345,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_user_fallback"
|
const sessionID = "ses_status_retry_user_fallback"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
const pluginConfig = {
|
const pluginConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
sisyphus: {
|
sisyphus: {
|
||||||
@@ -446,9 +444,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
const toastCalls: string[] = []
|
const toastCalls: string[] = []
|
||||||
const sessionID = "ses_main_fallback_chain"
|
const sessionID = "ses_main_fallback_chain"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
setupConnectedProviderCacheMocks()
|
setupConnectedProviderCacheMocks()
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
|
|||||||
@@ -761,11 +761,10 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_retry_recovery_rearm"
|
const sessionID = "ses_retry_recovery_rearm"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const abortCalls: string[] = []
|
const abortCalls: string[] = []
|
||||||
const promptCalls: string[] = []
|
const promptCalls: string[] = []
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({
|
ctx: asEventHandlerContext({
|
||||||
|
|||||||
+22
-9
@@ -15,6 +15,7 @@ import {
|
|||||||
clearSessionFallbackChain,
|
clearSessionFallbackChain,
|
||||||
setSessionFallbackChain,
|
setSessionFallbackChain,
|
||||||
setPendingModelFallback,
|
setPendingModelFallback,
|
||||||
|
type ModelFallbackHook,
|
||||||
} from "../hooks/model-fallback/hook";
|
} from "../hooks/model-fallback/hook";
|
||||||
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
|
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
|
||||||
import {
|
import {
|
||||||
@@ -111,6 +112,7 @@ function extractProviderModelFromErrorMessage(message: string): { providerID?: s
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
function applyUserConfiguredFallbackChain(
|
function applyUserConfiguredFallbackChain(
|
||||||
|
modelFallback: Pick<ModelFallbackHook, "setSessionFallbackChain"> | null | undefined,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
currentProviderID: string,
|
currentProviderID: string,
|
||||||
@@ -123,7 +125,9 @@ function applyUserConfiguredFallbackChain(
|
|||||||
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
|
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
|
||||||
|
|
||||||
if (fallbackChain && fallbackChain.length > 0) {
|
if (fallbackChain && fallbackChain.length > 0) {
|
||||||
setSessionFallbackChain(sessionID, fallbackChain);
|
if (modelFallback) {
|
||||||
|
setSessionFallbackChain(modelFallback, sessionID, fallbackChain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +174,7 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
const isModelFallbackEnabled =
|
const isModelFallbackEnabled =
|
||||||
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
|
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
|
||||||
|
const modelFallback = hooks.modelFallback;
|
||||||
|
|
||||||
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
||||||
const lastHandledModelErrorMessageID = new Map<string, string>();
|
const lastHandledModelErrorMessageID = new Map<string, string>();
|
||||||
@@ -408,8 +413,10 @@ export function createEventHandler(args: {
|
|||||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
||||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
||||||
lastKnownModelBySession.delete(sessionInfo.id);
|
lastKnownModelBySession.delete(sessionInfo.id);
|
||||||
clearPendingModelFallback(sessionInfo.id);
|
if (modelFallback) {
|
||||||
clearSessionFallbackChain(sessionInfo.id);
|
clearPendingModelFallback(modelFallback, sessionInfo.id);
|
||||||
|
clearSessionFallbackChain(modelFallback, sessionInfo.id);
|
||||||
|
}
|
||||||
resetMessageCursor(sessionInfo.id);
|
resetMessageCursor(sessionInfo.id);
|
||||||
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
|
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
|
||||||
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
|
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
|
||||||
@@ -517,9 +524,11 @@ export function createEventHandler(args: {
|
|||||||
);
|
);
|
||||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7";
|
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7";
|
||||||
const currentModel = normalizeFallbackModelID(rawModel);
|
const currentModel = normalizeFallbackModelID(rawModel);
|
||||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
const setFallback = modelFallback
|
||||||
|
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
setFallback &&
|
setFallback &&
|
||||||
@@ -580,9 +589,11 @@ export function createEventHandler(args: {
|
|||||||
const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID);
|
const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID);
|
||||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7";
|
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7";
|
||||||
currentModel = normalizeFallbackModelID(currentModel);
|
currentModel = normalizeFallbackModelID(currentModel);
|
||||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
const setFallback = modelFallback
|
||||||
|
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
setFallback &&
|
setFallback &&
|
||||||
@@ -666,9 +677,11 @@ export function createEventHandler(args: {
|
|||||||
);
|
);
|
||||||
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7";
|
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7";
|
||||||
currentModel = normalizeFallbackModelID(currentModel);
|
currentModel = normalizeFallbackModelID(currentModel);
|
||||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
const setFallback = modelFallback
|
||||||
|
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
setFallback &&
|
setFallback &&
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { createModelFallbackHook } from "../hooks/model-fallback/hook"
|
|||||||
import { createRuntimeFallbackHook } from "../hooks/runtime-fallback"
|
import { createRuntimeFallbackHook } from "../hooks/runtime-fallback"
|
||||||
import type { RuntimeFallbackPluginInput } from "../hooks/runtime-fallback/types"
|
import type { RuntimeFallbackPluginInput } from "../hooks/runtime-fallback/types"
|
||||||
import { _resetForTesting } from "../features/claude-code-session-state"
|
import { _resetForTesting } from "../features/claude-code-session-state"
|
||||||
import { _resetForTesting as _resetModelFallbackForTesting } from "../hooks/model-fallback/hook"
|
|
||||||
import { SessionCategoryRegistry } from "../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../shared/session-category-registry"
|
||||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||||
|
|
||||||
@@ -369,7 +368,6 @@ function setupConnectedProviderCacheMocks(): void {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
_resetModelFallbackForTesting()
|
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { PluginContext } from "../types"
|
import type { PluginContext } from "../types"
|
||||||
import type { ModelCacheState } from "../../plugin-state"
|
import type { ModelCacheState } from "../../plugin-state"
|
||||||
|
|
||||||
@@ -10,15 +11,17 @@ export function createCoreHooks(args: {
|
|||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
isHookEnabled: (hookName: HookName) => boolean
|
isHookEnabled: (hookName: HookName) => boolean
|
||||||
safeHookEnabled: boolean
|
safeHookEnabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
|
||||||
|
|
||||||
const session = createSessionHooks({
|
const session = createSessionHooks({
|
||||||
ctx,
|
ctx,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { ModelCacheState } from "../../plugin-state"
|
import type { ModelCacheState } from "../../plugin-state"
|
||||||
import type { PluginContext } from "../types"
|
import type { PluginContext } from "../types"
|
||||||
|
|
||||||
@@ -69,10 +70,11 @@ export function createSessionHooks(args: {
|
|||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
isHookEnabled: (hookName: HookName) => boolean
|
isHookEnabled: (hookName: HookName) => boolean
|
||||||
safeHookEnabled: boolean
|
safeHookEnabled: boolean
|
||||||
}): SessionHooks {
|
}): SessionHooks {
|
||||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
|
||||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ export function createSessionHooks(args: {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
},
|
},
|
||||||
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
|
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
|
||||||
|
controllerAccessor: modelFallbackControllerAccessor,
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): vo
|
|||||||
export function createToolRegistry(args: {
|
export function createToolRegistry(args: {
|
||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
|
||||||
skillContext: SkillContext
|
skillContext: SkillContext
|
||||||
availableCategories: AvailableCategory[]
|
availableCategories: AvailableCategory[]
|
||||||
interactiveBashEnabled?: boolean
|
interactiveBashEnabled?: boolean
|
||||||
@@ -170,6 +170,7 @@ export function createToolRegistry(args: {
|
|||||||
pluginConfig.disabled_agents ?? [],
|
pluginConfig.disabled_agents ?? [],
|
||||||
pluginConfig.agents,
|
pluginConfig.agents,
|
||||||
pluginConfig.categories,
|
pluginConfig.categories,
|
||||||
|
managers.modelFallbackControllerAccessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
||||||
@@ -191,6 +192,7 @@ export function createToolRegistry(args: {
|
|||||||
availableSkills: skillContext.availableSkills,
|
availableSkills: skillContext.availableSkills,
|
||||||
sisyphusAgentConfig: pluginConfig.sisyphus_agent,
|
sisyphusAgentConfig: pluginConfig.sisyphus_agent,
|
||||||
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
|
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
|
||||||
|
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
|
||||||
onSyncSessionCreated: async (event) => {
|
onSyncSessionCreated: async (event) => {
|
||||||
log("[index] onSyncSessionCreated callback", {
|
log("[index] onSyncSessionCreated callback", {
|
||||||
sessionID: event.sessionID,
|
sessionID: event.sessionID,
|
||||||
|
|||||||
@@ -78,3 +78,4 @@ export * from "./plugin-identity"
|
|||||||
export * from "./log-legacy-plugin-startup-warning"
|
export * from "./log-legacy-plugin-startup-warning"
|
||||||
export * from "./task-system-enabled"
|
export * from "./task-system-enabled"
|
||||||
export * from "./parse-tools-config"
|
export * from "./parse-tools-config"
|
||||||
|
export { parseModelString } from "./model-string-parser"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("logLegacyPluginStartupWarning", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(mockLog).toHaveBeenCalledTimes(1)
|
expect(mockLog).toHaveBeenCalledTimes(1)
|
||||||
expect(mockLog).toHaveBeenCalledWith(
|
expect(mockLog).toHaveBeenCalledWith(
|
||||||
"[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config",
|
"[legacy-migration] Legacy plugin entry detected in OpenCode config",
|
||||||
{
|
{
|
||||||
legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"],
|
legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"],
|
||||||
suggestedEntries: ["oh-my-openagent", "oh-my-openagent@3.13.1"],
|
suggestedEntries: ["oh-my-openagent", "oh-my-openagent@3.13.1"],
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function logLegacyPluginStartupWarning(deps: LogLegacyPluginStartupWarnin
|
|||||||
|
|
||||||
const suggestedEntries = result.legacyEntries.map(toCanonicalEntry)
|
const suggestedEntries = result.legacyEntries.map(toCanonicalEntry)
|
||||||
|
|
||||||
logFn("[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config", {
|
logFn("[legacy-migration] Legacy plugin entry detected in OpenCode config", {
|
||||||
legacyEntries: result.legacyEntries,
|
legacyEntries: result.legacyEntries,
|
||||||
suggestedEntries,
|
suggestedEntries,
|
||||||
hasCanonicalEntry: result.hasCanonicalEntry,
|
hasCanonicalEntry: result.hasCanonicalEntry,
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export function parseModelString(
|
|||||||
const trimmedModel = model.trim()
|
const trimmedModel = model.trim()
|
||||||
if (!trimmedModel) return undefined
|
if (!trimmedModel) return undefined
|
||||||
|
|
||||||
const parts = trimmedModel.split("/")
|
const separatorIndex = trimmedModel.indexOf("/")
|
||||||
if (parts.length < 2) {
|
if (separatorIndex === -1) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerID = parts[0]?.trim()
|
const providerID = trimmedModel.slice(0, separatorIndex).trim()
|
||||||
const rawModelID = parts.slice(1).join("/").trim()
|
const rawModelID = trimmedModel.slice(separatorIndex + 1).trim()
|
||||||
if (!providerID || !rawModelID) {
|
if (!providerID || !rawModelID) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { spawnSync } from "node:child_process"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { downloadAndInstallRipgrep, getInstalledRipgrepPath } from "../tools/grep/downloader"
|
||||||
|
import { getDataDir } from "./data-path"
|
||||||
|
import { log } from "./logger"
|
||||||
|
import { PUBLISHED_PACKAGE_NAME } from "./plugin-identity"
|
||||||
|
|
||||||
|
export type GrepBackend = "rg" | "grep"
|
||||||
|
|
||||||
|
export interface ResolvedCli {
|
||||||
|
path: string
|
||||||
|
backend: GrepBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RG_THREADS = 4
|
||||||
|
|
||||||
|
let cachedCli: ResolvedCli | null = null
|
||||||
|
let autoInstallAttempted = false
|
||||||
|
|
||||||
|
function findExecutable(name: string): string | null {
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const cmd = isWindows ? "where" : "which"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
|
||||||
|
if (result.status === 0 && result.stdout.trim()) {
|
||||||
|
return result.stdout.trim().split("\n")[0]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenCodeBundledRg(): string | null {
|
||||||
|
const execPath = process.execPath
|
||||||
|
const execDir = dirname(execPath)
|
||||||
|
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const rgName = isWindows ? "rg.exe" : "rg"
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
join(getDataDir(), "opencode", "bin", rgName),
|
||||||
|
join(execDir, rgName),
|
||||||
|
join(execDir, "bin", rgName),
|
||||||
|
join(execDir, "..", "bin", rgName),
|
||||||
|
join(execDir, "..", "libexec", rgName),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGrepCli(): ResolvedCli {
|
||||||
|
if (cachedCli) {
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgPath = getOpenCodeBundledRg() ?? findExecutable("rg") ?? getInstalledRipgrepPath()
|
||||||
|
if (rgPath) {
|
||||||
|
cachedCli = { path: rgPath, backend: "rg" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
const grep = findExecutable("grep")
|
||||||
|
if (grep) {
|
||||||
|
cachedCli = { path: grep, backend: "grep" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedCli = { path: "rg", backend: "rg" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
|
||||||
|
const current = resolveGrepCli()
|
||||||
|
|
||||||
|
if (current.backend === "rg" && current.path !== "rg") {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoInstallAttempted) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
autoInstallAttempted = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rgPath = await downloadAndInstallRipgrep()
|
||||||
|
cachedCli = { path: rgPath, backend: "rg" }
|
||||||
|
return cachedCli
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
if (current.backend === "grep") {
|
||||||
|
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
|
||||||
|
error: message,
|
||||||
|
grep_path: current.path,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
|
||||||
|
error: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { CallOmoAgentArgs } from "./types"
|
import type { CallOmoAgentArgs } from "./types"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||||
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
|
||||||
import { getAgentToolRestrictions, log } from "../../shared"
|
import { getAgentToolRestrictions, log } from "../../shared"
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||||
@@ -19,8 +18,8 @@ type ExecuteSyncDeps = {
|
|||||||
createOrGetSession: typeof createOrGetSession
|
createOrGetSession: typeof createOrGetSession
|
||||||
waitForCompletion: typeof waitForCompletion
|
waitForCompletion: typeof waitForCompletion
|
||||||
processMessages: typeof processMessages
|
processMessages: typeof processMessages
|
||||||
setSessionFallbackChain: typeof setSessionFallbackChain
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
||||||
clearSessionFallbackChain: typeof clearSessionFallbackChain
|
clearSessionFallbackChain: (sessionID: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpawnReservation = {
|
type SpawnReservation = {
|
||||||
@@ -32,8 +31,8 @@ const defaultDeps: ExecuteSyncDeps = {
|
|||||||
createOrGetSession,
|
createOrGetSession,
|
||||||
waitForCompletion,
|
waitForCompletion,
|
||||||
processMessages,
|
processMessages,
|
||||||
setSessionFallbackChain,
|
setSessionFallbackChain: () => {},
|
||||||
clearSessionFallbackChain,
|
clearSessionFallbackChain: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||||
import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
|
import type { CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
|
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
|
||||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
@@ -11,10 +12,27 @@ import { normalizeFallbackModels } from "../../shared/model-resolver"
|
|||||||
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
||||||
import { parseModelString } from "../delegate-task/model-string-parser"
|
import { parseModelString } from "../../shared"
|
||||||
import { executeBackground } from "./background-executor"
|
import { executeBackground } from "./background-executor"
|
||||||
import { executeSync } from "./sync-executor"
|
import { executeSync } from "./sync-executor"
|
||||||
import { resolveCallableAgents } from "./agent-resolver"
|
import { resolveCallableAgents } from "./agent-resolver"
|
||||||
|
import { createOrGetSession } from "./session-creator"
|
||||||
|
import { processMessages } from "./message-processor"
|
||||||
|
import { waitForCompletion } from "./completion-poller"
|
||||||
|
|
||||||
|
function createSyncExecutorDeps(modelFallbackControllerAccessor?: ModelFallbackControllerAccessor) {
|
||||||
|
return {
|
||||||
|
createOrGetSession,
|
||||||
|
waitForCompletion,
|
||||||
|
processMessages,
|
||||||
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => {
|
||||||
|
modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
},
|
||||||
|
clearSessionFallbackChain: (sessionID: string) => {
|
||||||
|
modelFallbackControllerAccessor?.clearSessionFallbackChain(sessionID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModelAndFallbackChain(args: {
|
function resolveModelAndFallbackChain(args: {
|
||||||
subagentType: string
|
subagentType: string
|
||||||
@@ -82,6 +100,7 @@ export function createCallOmoAgent(
|
|||||||
disabledAgents: string[] = [],
|
disabledAgents: string[] = [],
|
||||||
agentOverrides?: AgentOverrides,
|
agentOverrides?: AgentOverrides,
|
||||||
userCategories?: CategoriesConfig,
|
userCategories?: CategoriesConfig,
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor,
|
||||||
): ToolDefinition {
|
): ToolDefinition {
|
||||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||||
(name) => `- ${name}: Specialized agent for ${name} tasks`,
|
(name) => `- ${name}: Specialized agent for ${name} tasks`,
|
||||||
@@ -158,14 +177,30 @@ export function createCallOmoAgent(
|
|||||||
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
||||||
try {
|
try {
|
||||||
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
||||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation, resolvedModel)
|
return await executeSync(
|
||||||
|
args,
|
||||||
|
toolCtx,
|
||||||
|
ctx,
|
||||||
|
createSyncExecutorDeps(modelFallbackControllerAccessor),
|
||||||
|
fallbackChain,
|
||||||
|
spawnReservation,
|
||||||
|
resolvedModel,
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spawnReservation?.rollback()
|
spawnReservation?.rollback()
|
||||||
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel)
|
return await executeSync(
|
||||||
|
args,
|
||||||
|
toolCtx,
|
||||||
|
ctx,
|
||||||
|
createSyncExecutorDeps(modelFallbackControllerAccessor),
|
||||||
|
fallbackChain,
|
||||||
|
undefined,
|
||||||
|
resolvedModel,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { formatDetailedError } from "./error-formatting"
|
|||||||
import { getSessionTools } from "../../shared/session-tools-store"
|
import { getSessionTools } from "../../shared/session-tools-store"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||||
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
|
||||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||||
@@ -19,6 +18,7 @@ function continueSessionSetup(args: {
|
|||||||
timing: ReturnType<typeof getTimingConfig>
|
timing: ReturnType<typeof getTimingConfig>
|
||||||
fallbackChain?: FallbackEntry[]
|
fallbackChain?: FallbackEntry[]
|
||||||
category?: string
|
category?: string
|
||||||
|
modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"]
|
||||||
}): void {
|
}): void {
|
||||||
if (!args.fallbackChain && !args.category) {
|
if (!args.fallbackChain && !args.category) {
|
||||||
return
|
return
|
||||||
@@ -41,7 +41,7 @@ function continueSessionSetup(args: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionId, args.fallbackChain)
|
args.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, args.fallbackChain)
|
||||||
if (args.category) {
|
if (args.category) {
|
||||||
SessionCategoryRegistry.register(sessionId, args.category)
|
SessionCategoryRegistry.register(sessionId, args.category)
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,7 @@ export async function executeBackgroundTask(
|
|||||||
timing,
|
timing,
|
||||||
fallbackChain,
|
fallbackChain,
|
||||||
category: args.category,
|
category: args.category,
|
||||||
|
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ export async function executeBackgroundTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setSessionFallbackChain(sessionId, fallbackChain)
|
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, fallbackChain)
|
||||||
}
|
}
|
||||||
if (args.category && sessionId) {
|
if (args.category && sessionId) {
|
||||||
SessionCategoryRegistry.register(sessionId, args.category)
|
SessionCategoryRegistry.register(sessionId, args.category)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { FallbackEntry } from "../../shared/model-requirements"
|
|||||||
import { mergeCategories } from "../../shared/merge-categories"
|
import { mergeCategories } from "../../shared/merge-categories"
|
||||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||||
import { resolveCategoryConfig } from "./categories"
|
import { resolveCategoryConfig } from "./categories"
|
||||||
import { parseModelString } from "./model-string-parser"
|
import { parseModelString } from "../../shared/model-string-parser"
|
||||||
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||||
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient } from "./types"
|
||||||
|
|
||||||
export interface ExecutorContext {
|
export interface ExecutorContext {
|
||||||
@@ -12,6 +13,7 @@ export interface ExecutorContext {
|
|||||||
browserProvider?: BrowserAutomationProvider
|
browserProvider?: BrowserAutomationProvider
|
||||||
agentOverrides?: AgentOverrides
|
agentOverrides?: AgentOverrides
|
||||||
sisyphusAgentConfig?: SisyphusAgentConfig
|
sisyphusAgentConfig?: SisyphusAgentConfig
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
||||||
syncPollTimeoutMs?: number
|
syncPollTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fuzzyMatchModel } from "../../shared/model-availability"
|
|||||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||||
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache"
|
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { parseModelString, parseVariantFromModelID } from "./model-string-parser"
|
import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser"
|
||||||
|
|
||||||
function isExplicitHighModel(model: string): boolean {
|
function isExplicitHighModel(model: string): boolean {
|
||||||
return /(?:^|\/)[^/]+-high$/.test(model)
|
return /(?:^|\/)[^/]+-high$/.test(model)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
|||||||
import { formatDuration } from "./time-formatter"
|
import { formatDuration } from "./time-formatter"
|
||||||
import { formatDetailedError } from "./error-formatting"
|
import { formatDetailedError } from "./error-formatting"
|
||||||
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
||||||
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
|
||||||
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||||
@@ -81,7 +80,7 @@ export async function executeSyncTask(
|
|||||||
subagentSessions.add(sessionID)
|
subagentSessions.add(sessionID)
|
||||||
syncSubagentSessions.add(sessionID)
|
syncSubagentSessions.add(sessionID)
|
||||||
setSessionAgent(sessionID, agentToUse)
|
setSessionAgent(sessionID, agentToUse)
|
||||||
setSessionFallbackChain(sessionID, fallbackChain)
|
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
|
||||||
if (args.category) {
|
if (args.category) {
|
||||||
SessionCategoryRegistry.register(sessionID, args.category)
|
SessionCategoryRegistry.register(sessionID, args.category)
|
||||||
@@ -237,7 +236,7 @@ ${buildTaskMetadataBlock({
|
|||||||
if (syncSessionID) {
|
if (syncSessionID) {
|
||||||
subagentSessions.delete(syncSessionID)
|
subagentSessions.delete(syncSessionID)
|
||||||
syncSubagentSessions.delete(syncSessionID)
|
syncSubagentSessions.delete(syncSessionID)
|
||||||
clearSessionFallbackChain(syncSessionID)
|
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
|
||||||
SessionCategoryRegistry.remove(syncSessionID)
|
SessionCategoryRegistry.remove(syncSessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||||
|
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export async function prepareDelegateTaskArgs(args: Record<string, unknown>, ctx: ToolContextWithMetadata): Promise<DelegateTaskArgs> {
|
||||||
|
const category = typeof args.category === "string" ? args.category : undefined
|
||||||
|
const prompt = typeof args.prompt === "string" ? args.prompt : ""
|
||||||
|
const originalSubagentType = typeof args.subagent_type === "string" ? args.subagent_type : undefined
|
||||||
|
let subagentType = originalSubagentType
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
if (subagentType && subagentType !== SISYPHUS_JUNIOR_AGENT) {
|
||||||
|
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
|
||||||
|
category,
|
||||||
|
subagent_type: subagentType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
subagentType = SISYPHUS_JUNIOR_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = typeof args.description === "string" ? args.description : undefined
|
||||||
|
if (!description || description.trim() === "") {
|
||||||
|
const words = prompt.trim().split(/\s+/)
|
||||||
|
description = words.slice(0, 4).join(" ") || "Delegated task"
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.metadata?.({
|
||||||
|
title: description,
|
||||||
|
})
|
||||||
|
|
||||||
|
const runInBackground = args.run_in_background
|
||||||
|
if (runInBackground === undefined) {
|
||||||
|
throw new Error("Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadSkills = args.load_skills
|
||||||
|
if (typeof loadSkills === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(loadSkills)
|
||||||
|
loadSkills = Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
loadSkills = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadSkills === undefined) {
|
||||||
|
throw new Error("Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadSkills === null) {
|
||||||
|
throw new Error("Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLoadSkills = Array.isArray(loadSkills)
|
||||||
|
? loadSkills.filter((value): value is string => typeof value === "string")
|
||||||
|
: []
|
||||||
|
|
||||||
|
const taskID = typeof args.task_id === "string" ? args.task_id : undefined
|
||||||
|
const command = typeof args.command === "string" ? args.command : undefined
|
||||||
|
|
||||||
|
args.category = category
|
||||||
|
args.subagent_type = subagentType
|
||||||
|
args.description = description
|
||||||
|
args.prompt = prompt
|
||||||
|
args.run_in_background = runInBackground
|
||||||
|
args.task_id = taskID
|
||||||
|
args.command = command
|
||||||
|
args.load_skills = normalizedLoadSkills
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
subagent_type: subagentType,
|
||||||
|
description,
|
||||||
|
prompt,
|
||||||
|
run_in_background: runInBackground === true,
|
||||||
|
task_id: taskID,
|
||||||
|
command,
|
||||||
|
load_skills: normalizedLoadSkills,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { AvailableCategory, AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||||
|
import { mergeCategories } from "../../shared/merge-categories"
|
||||||
|
import { CATEGORY_DESCRIPTIONS } from "./constants"
|
||||||
|
import type { DelegateTaskToolOptions } from "./types"
|
||||||
|
|
||||||
|
export interface DelegateTaskPresentation {
|
||||||
|
availableCategories: AvailableCategory[]
|
||||||
|
availableSkills: AvailableSkill[]
|
||||||
|
categoryExamples: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDelegateTaskPresentation(options: DelegateTaskToolOptions): DelegateTaskPresentation {
|
||||||
|
const { userCategories } = options
|
||||||
|
const allCategories = mergeCategories(userCategories)
|
||||||
|
const categoryEntries = Object.entries(allCategories).map(([name, categoryConfig]) => ({
|
||||||
|
name,
|
||||||
|
categoryConfig,
|
||||||
|
description: userCategories?.[name]?.description || CATEGORY_DESCRIPTIONS[name],
|
||||||
|
}))
|
||||||
|
const categoryNames = categoryEntries.map(({ name }) => name)
|
||||||
|
const categoryExamples = categoryNames.join(", ")
|
||||||
|
|
||||||
|
const availableCategories: AvailableCategory[] = options.availableCategories
|
||||||
|
?? categoryEntries.map(({ name, categoryConfig, description }) => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: description || "General tasks",
|
||||||
|
model: categoryConfig.model,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableSkills: AvailableSkill[] = options.availableSkills ?? []
|
||||||
|
|
||||||
|
const categoryList = categoryEntries.map(({ name, description }) => {
|
||||||
|
return description ? ` - ${name}: ${description}` : ` - ${name}`
|
||||||
|
}).join("\n")
|
||||||
|
|
||||||
|
const description = `Spawn agent task with category-based or direct agent selection.
|
||||||
|
|
||||||
|
⚠️ CRITICAL: You MUST provide EITHER category OR subagent_type. Omitting BOTH will FAIL.
|
||||||
|
|
||||||
|
**COMMON MISTAKE (DO NOT DO THIS):**
|
||||||
|
\`\`\`
|
||||||
|
task(description="...", prompt="...", run_in_background=false) // ❌ FAILS - missing category AND subagent_type
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**CORRECT - Using category:**
|
||||||
|
\`\`\`
|
||||||
|
task(category="quick", load_skills=[], description="Fix type error", prompt="...", run_in_background=false)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**CORRECT - Using subagent_type:**
|
||||||
|
\`\`\`
|
||||||
|
task(subagent_type="explore", load_skills=[], description="Find patterns", prompt="...", run_in_background=true)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
REQUIRED: Provide ONE of:
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||||
|
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
|
||||||
|
- command: The command that triggered this task (optional, for slash command tracking).
|
||||||
|
|
||||||
|
**WHEN TO USE task_id:**
|
||||||
|
- Task failed/incomplete → task_id with "fix: [specific issue]"
|
||||||
|
- Need follow-up on previous result → task_id with additional question
|
||||||
|
- Multi-turn conversation with same agent → always task_id instead of new task
|
||||||
|
|
||||||
|
Prompts MUST be in English.`
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableCategories,
|
||||||
|
availableSkills,
|
||||||
|
categoryExamples,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import type { DelegateTaskArgs, DelegatedModelConfig, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
|
import type { DelegatedModelConfig, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
|
||||||
import { CATEGORY_DESCRIPTIONS } from "./constants"
|
|
||||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
|
||||||
import { mergeCategories } from "../../shared/merge-categories"
|
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { buildSystemContent } from "./prompt-builder"
|
import { buildSystemContent } from "./prompt-builder"
|
||||||
import type {
|
|
||||||
AvailableCategory,
|
|
||||||
AvailableSkill,
|
|
||||||
} from "../../agents/dynamic-agent-prompt-builder"
|
|
||||||
import {
|
import {
|
||||||
resolveSkillContent,
|
resolveSkillContent,
|
||||||
resolveParentContext,
|
resolveParentContext,
|
||||||
@@ -20,133 +13,37 @@ import {
|
|||||||
executeBackgroundTask,
|
executeBackgroundTask,
|
||||||
executeSyncTask,
|
executeSyncTask,
|
||||||
} from "./executor"
|
} from "./executor"
|
||||||
|
import { prepareDelegateTaskArgs } from "./tool-argument-preparation"
|
||||||
|
import { createDelegateTaskPresentation } from "./tool-description"
|
||||||
|
|
||||||
export { resolveCategoryConfig } from "./categories"
|
export { resolveCategoryConfig } from "./categories"
|
||||||
export type { SyncSessionCreatedEvent, DelegateTaskToolOptions, BuildSystemContentInput } from "./types"
|
export type { SyncSessionCreatedEvent, DelegateTaskToolOptions, BuildSystemContentInput } from "./types"
|
||||||
export { buildSystemContent, buildTaskPrompt } from "./prompt-builder"
|
export { buildSystemContent, buildTaskPrompt } from "./prompt-builder"
|
||||||
|
|
||||||
|
const delegateTaskArgsSchema = {
|
||||||
|
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||||
|
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
|
||||||
|
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."),
|
||||||
|
task_id: tool.schema.string().optional().describe("Existing task to continue. Canonical resume identifier."),
|
||||||
|
command: tool.schema.string().optional().describe("The command that triggered this task"),
|
||||||
|
}
|
||||||
|
|
||||||
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
||||||
const { userCategories } = options
|
const { availableCategories, availableSkills, categoryExamples, description } = createDelegateTaskPresentation(options)
|
||||||
|
|
||||||
const allCategories = mergeCategories(userCategories)
|
|
||||||
const categoryNames = Object.keys(allCategories)
|
|
||||||
const categoryExamples = categoryNames.join(", ")
|
|
||||||
|
|
||||||
const availableCategories: AvailableCategory[] = options.availableCategories
|
|
||||||
?? Object.entries(allCategories).map(([name, categoryConfig]) => {
|
|
||||||
const userDesc = userCategories?.[name]?.description
|
|
||||||
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
|
|
||||||
const description = userDesc || builtinDesc || "General tasks"
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
model: categoryConfig.model,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableSkills: AvailableSkill[] = options.availableSkills ?? []
|
|
||||||
|
|
||||||
const categoryList = categoryNames.map(name => {
|
|
||||||
const userDesc = userCategories?.[name]?.description
|
|
||||||
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
|
|
||||||
const desc = userDesc || builtinDesc
|
|
||||||
return desc ? ` - ${name}: ${desc}` : ` - ${name}`
|
|
||||||
}).join("\n")
|
|
||||||
|
|
||||||
const description = `Spawn agent task with category-based or direct agent selection.
|
|
||||||
|
|
||||||
⚠️ CRITICAL: You MUST provide EITHER category OR subagent_type. Omitting BOTH will FAIL.
|
|
||||||
|
|
||||||
**COMMON MISTAKE (DO NOT DO THIS):**
|
|
||||||
\`\`\`
|
|
||||||
task(description="...", prompt="...", run_in_background=false) // ❌ FAILS - missing category AND subagent_type
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**CORRECT - Using category:**
|
|
||||||
\`\`\`
|
|
||||||
task(category="quick", load_skills=[], description="Fix type error", prompt="...", run_in_background=false)
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**CORRECT - Using subagent_type:**
|
|
||||||
\`\`\`
|
|
||||||
task(subagent_type="explore", load_skills=[], description="Find patterns", prompt="...", run_in_background=true)
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
REQUIRED: Provide ONE of:
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
|
||||||
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
|
|
||||||
- command: The command that triggered this task (optional, for slash command tracking).
|
|
||||||
|
|
||||||
**WHEN TO USE task_id:**
|
|
||||||
- Task failed/incomplete → task_id with "fix: [specific issue]"
|
|
||||||
- Need follow-up on previous result → task_id with additional question
|
|
||||||
- Multi-turn conversation with same agent → always task_id instead of new task
|
|
||||||
|
|
||||||
Prompts MUST be in English.`
|
|
||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
description,
|
description,
|
||||||
args: {
|
args: delegateTaskArgsSchema,
|
||||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
async execute(args, toolContext) {
|
||||||
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
|
|
||||||
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."),
|
|
||||||
task_id: tool.schema.string().optional().describe("Existing task to continue. Canonical resume identifier."),
|
|
||||||
command: tool.schema.string().optional().describe("The command that triggered this task"),
|
|
||||||
},
|
|
||||||
async execute(args: DelegateTaskArgs, toolContext) {
|
|
||||||
const ctx = toolContext as ToolContextWithMetadata
|
const ctx = toolContext as ToolContextWithMetadata
|
||||||
|
const delegateTaskArgs = await prepareDelegateTaskArgs(args, ctx)
|
||||||
|
|
||||||
if (args.category) {
|
const runInBackground = delegateTaskArgs.run_in_background === true
|
||||||
if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) {
|
|
||||||
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
|
|
||||||
category: args.category,
|
|
||||||
subagent_type: args.subagent_type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
args.subagent_type = SISYPHUS_JUNIOR_AGENT
|
|
||||||
}
|
|
||||||
// Auto-generate description from prompt when missing or empty
|
|
||||||
if (!args.description || typeof args.description !== "string" || args.description.trim() === "") {
|
|
||||||
const words = (args.prompt || "").trim().split(/\s+/)
|
|
||||||
args.description = words.slice(0, 4).join(" ") || "Delegated task"
|
|
||||||
}
|
|
||||||
await ctx.metadata?.({
|
|
||||||
title: args.description,
|
|
||||||
})
|
|
||||||
if (args.run_in_background === undefined) {
|
|
||||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
|
|
||||||
}
|
|
||||||
if (typeof args.load_skills === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(args.load_skills)
|
|
||||||
args.load_skills = Array.isArray(parsed) ? parsed : []
|
|
||||||
} catch {
|
|
||||||
args.load_skills = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (args.load_skills === undefined) {
|
|
||||||
throw new Error(`Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed.`)
|
|
||||||
}
|
|
||||||
if (args.load_skills === null) {
|
|
||||||
throw new Error(`Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const runInBackground = args.run_in_background === true
|
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(delegateTaskArgs.load_skills, {
|
||||||
|
|
||||||
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(args.load_skills, {
|
|
||||||
gitMasterConfig: options.gitMasterConfig,
|
gitMasterConfig: options.gitMasterConfig,
|
||||||
browserProvider: options.browserProvider,
|
browserProvider: options.browserProvider,
|
||||||
disabledSkills: options.disabledSkills,
|
disabledSkills: options.disabledSkills,
|
||||||
@@ -158,14 +55,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
|
|
||||||
const parentContext = await resolveParentContext(ctx, options.client)
|
const parentContext = await resolveParentContext(ctx, options.client)
|
||||||
|
|
||||||
if (args.task_id) {
|
if (delegateTaskArgs.task_id) {
|
||||||
if (runInBackground) {
|
if (runInBackground) {
|
||||||
return executeBackgroundContinuation(args, ctx, options, parentContext)
|
return executeBackgroundContinuation(delegateTaskArgs, ctx, options, parentContext)
|
||||||
}
|
}
|
||||||
return executeSyncContinuation(args, ctx, options, parentContext)
|
return executeSyncContinuation(delegateTaskArgs, ctx, options, parentContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.category && !args.subagent_type) {
|
if (!delegateTaskArgs.category && !delegateTaskArgs.subagent_type) {
|
||||||
return `Invalid arguments: Must provide either category or subagent_type.`
|
return `Invalid arguments: Must provide either category or subagent_type.`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +87,8 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
|
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
|
||||||
let maxPromptTokens: number | undefined
|
let maxPromptTokens: number | undefined
|
||||||
|
|
||||||
if (args.category) {
|
if (delegateTaskArgs.category) {
|
||||||
const resolution = await resolveCategoryExecution(args, options, inheritedModel, systemDefaultModel)
|
const resolution = await resolveCategoryExecution(delegateTaskArgs, options, inheritedModel, systemDefaultModel)
|
||||||
if (resolution.error) {
|
if (resolution.error) {
|
||||||
return resolution.error
|
return resolution.error
|
||||||
}
|
}
|
||||||
@@ -204,14 +101,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
fallbackChain = resolution.fallbackChain
|
fallbackChain = resolution.fallbackChain
|
||||||
maxPromptTokens = resolution.maxPromptTokens
|
maxPromptTokens = resolution.maxPromptTokens
|
||||||
|
|
||||||
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
|
const isRunInBackgroundExplicitlyFalse = isExplicitSyncRun(delegateTaskArgs.run_in_background)
|
||||||
|
|
||||||
log("[task] unstable agent detection", {
|
log("[task] unstable agent detection", {
|
||||||
category: args.category,
|
category: delegateTaskArgs.category,
|
||||||
actualModel,
|
actualModel,
|
||||||
isUnstableAgent,
|
isUnstableAgent,
|
||||||
run_in_background_value: args.run_in_background,
|
run_in_background_value: delegateTaskArgs.run_in_background,
|
||||||
run_in_background_type: typeof args.run_in_background,
|
run_in_background_type: typeof delegateTaskArgs.run_in_background,
|
||||||
isRunInBackgroundExplicitlyFalse,
|
isRunInBackgroundExplicitlyFalse,
|
||||||
willForceBackground: isUnstableAgent && isRunInBackgroundExplicitlyFalse,
|
willForceBackground: isUnstableAgent && isRunInBackgroundExplicitlyFalse,
|
||||||
})
|
})
|
||||||
@@ -227,10 +124,10 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
availableCategories,
|
availableCategories,
|
||||||
availableSkills,
|
availableSkills,
|
||||||
})
|
})
|
||||||
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
return executeUnstableAgentTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
|
const resolution = await resolveSubagentExecution(delegateTaskArgs, options, parentContext.agent, categoryExamples)
|
||||||
if (resolution.error) {
|
if (resolution.error) {
|
||||||
return resolution.error
|
return resolution.error
|
||||||
}
|
}
|
||||||
@@ -251,10 +148,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (runInBackground) {
|
if (runInBackground) {
|
||||||
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
|
return executeBackgroundTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
|
return executeSyncTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExplicitSyncRun(runInBackground: unknown): boolean {
|
||||||
|
return runInBackground === false || runInBackground === "false"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type {
|
import type {
|
||||||
AvailableCategory,
|
AvailableCategory,
|
||||||
AvailableSkill,
|
AvailableSkill,
|
||||||
@@ -68,6 +69,7 @@ export interface DelegateTaskToolOptions {
|
|||||||
availableSkills?: AvailableSkill[]
|
availableSkills?: AvailableSkill[]
|
||||||
agentOverrides?: AgentOverrides
|
agentOverrides?: AgentOverrides
|
||||||
sisyphusAgentConfig?: SisyphusAgentConfig
|
sisyphusAgentConfig?: SisyphusAgentConfig
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
|
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
|
||||||
syncPollTimeoutMs?: number
|
syncPollTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../grep/constants"
|
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../../shared/ripgrep-cli"
|
||||||
|
|
||||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||||
export const DEFAULT_LIMIT = 100
|
export const DEFAULT_LIMIT = 100
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import {
|
|||||||
resolveGrepCli,
|
resolveGrepCli,
|
||||||
type ResolvedCli,
|
type ResolvedCli,
|
||||||
type GrepBackend,
|
type GrepBackend,
|
||||||
|
DEFAULT_RG_THREADS,
|
||||||
|
} from "../../shared/ripgrep-cli"
|
||||||
|
import {
|
||||||
DEFAULT_MAX_DEPTH,
|
DEFAULT_MAX_DEPTH,
|
||||||
DEFAULT_MAX_FILESIZE,
|
DEFAULT_MAX_FILESIZE,
|
||||||
DEFAULT_MAX_COUNT,
|
DEFAULT_MAX_COUNT,
|
||||||
DEFAULT_MAX_COLUMNS,
|
DEFAULT_MAX_COLUMNS,
|
||||||
DEFAULT_TIMEOUT_MS,
|
DEFAULT_TIMEOUT_MS,
|
||||||
DEFAULT_MAX_OUTPUT_BYTES,
|
DEFAULT_MAX_OUTPUT_BYTES,
|
||||||
DEFAULT_RG_THREADS,
|
|
||||||
RG_SAFETY_FLAGS,
|
RG_SAFETY_FLAGS,
|
||||||
GREP_SAFETY_FLAGS,
|
GREP_SAFETY_FLAGS,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
|
|||||||
@@ -1,126 +1,3 @@
|
|||||||
import { existsSync } from "node:fs"
|
|
||||||
import { join, dirname } from "node:path"
|
|
||||||
import { spawnSync } from "node:child_process"
|
|
||||||
import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
|
|
||||||
import { getDataDir } from "../../shared/data-path"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import { PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
|
|
||||||
|
|
||||||
export type GrepBackend = "rg" | "grep"
|
|
||||||
|
|
||||||
export interface ResolvedCli {
|
|
||||||
path: string
|
|
||||||
backend: GrepBackend
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedCli: ResolvedCli | null = null
|
|
||||||
let autoInstallAttempted = false
|
|
||||||
|
|
||||||
function findExecutable(name: string): string | null {
|
|
||||||
const isWindows = process.platform === "win32"
|
|
||||||
const cmd = isWindows ? "where" : "which"
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
|
|
||||||
if (result.status === 0 && result.stdout.trim()) {
|
|
||||||
return result.stdout.trim().split("\n")[0]
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Command execution failed
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpenCodeBundledRg(): string | null {
|
|
||||||
const execPath = process.execPath
|
|
||||||
const execDir = dirname(execPath)
|
|
||||||
|
|
||||||
const isWindows = process.platform === "win32"
|
|
||||||
const rgName = isWindows ? "rg.exe" : "rg"
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
// OpenCode XDG data path (highest priority - where OpenCode installs rg)
|
|
||||||
join(getDataDir(), "opencode", "bin", rgName),
|
|
||||||
// Legacy paths relative to execPath
|
|
||||||
join(execDir, rgName),
|
|
||||||
join(execDir, "bin", rgName),
|
|
||||||
join(execDir, "..", "bin", rgName),
|
|
||||||
join(execDir, "..", "libexec", rgName),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGrepCli(): ResolvedCli {
|
|
||||||
if (cachedCli) return cachedCli
|
|
||||||
|
|
||||||
const bundledRg = getOpenCodeBundledRg()
|
|
||||||
if (bundledRg) {
|
|
||||||
cachedCli = { path: bundledRg, backend: "rg" }
|
|
||||||
return cachedCli
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemRg = findExecutable("rg")
|
|
||||||
if (systemRg) {
|
|
||||||
cachedCli = { path: systemRg, backend: "rg" }
|
|
||||||
return cachedCli
|
|
||||||
}
|
|
||||||
|
|
||||||
const installedRg = getInstalledRipgrepPath()
|
|
||||||
if (installedRg) {
|
|
||||||
cachedCli = { path: installedRg, backend: "rg" }
|
|
||||||
return cachedCli
|
|
||||||
}
|
|
||||||
|
|
||||||
const grep = findExecutable("grep")
|
|
||||||
if (grep) {
|
|
||||||
cachedCli = { path: grep, backend: "grep" }
|
|
||||||
return cachedCli
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedCli = { path: "rg", backend: "rg" }
|
|
||||||
return cachedCli
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
|
|
||||||
const current = resolveGrepCli()
|
|
||||||
|
|
||||||
if (current.backend === "rg" && current.path !== "rg") {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoInstallAttempted) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
autoInstallAttempted = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rgPath = await downloadAndInstallRipgrep()
|
|
||||||
cachedCli = { path: rgPath, backend: "rg" }
|
|
||||||
return cachedCli
|
|
||||||
} catch (error) {
|
|
||||||
if (current.backend === "grep") {
|
|
||||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
grep_path: current.path,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_MAX_DEPTH = 20
|
export const DEFAULT_MAX_DEPTH = 20
|
||||||
export const DEFAULT_MAX_FILESIZE = "10M"
|
export const DEFAULT_MAX_FILESIZE = "10M"
|
||||||
export const DEFAULT_MAX_COUNT = 500
|
export const DEFAULT_MAX_COUNT = 500
|
||||||
@@ -128,7 +5,6 @@ export const DEFAULT_MAX_COLUMNS = 1000
|
|||||||
export const DEFAULT_CONTEXT = 2
|
export const DEFAULT_CONTEXT = 2
|
||||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||||
export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
|
export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
|
||||||
export const DEFAULT_RG_THREADS = 4
|
|
||||||
|
|
||||||
export const RG_SAFETY_FLAGS = [
|
export const RG_SAFETY_FLAGS = [
|
||||||
"--no-follow",
|
"--no-follow",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
|
import { resolveGrepCliWithAutoInstall } from "../../shared/ripgrep-cli"
|
||||||
import { runRg, runRgCount } from "./cli"
|
import { runRg, runRgCount } from "./cli"
|
||||||
import { resolveGrepCliWithAutoInstall } from "./constants"
|
|
||||||
import { formatGrepResult, formatCountResult } from "./result-formatter"
|
import { formatGrepResult, formatCountResult } from "./result-formatter"
|
||||||
|
|
||||||
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { basename } from "node:path"
|
||||||
|
import { pathToFileURL } from "node:url"
|
||||||
|
import type { LookAtArgs } from "./types"
|
||||||
|
import {
|
||||||
|
extractBase64Data,
|
||||||
|
inferMimeTypeFromBase64,
|
||||||
|
inferMimeTypeFromFilePath,
|
||||||
|
} from "./mime-type-inference"
|
||||||
|
import {
|
||||||
|
needsConversion,
|
||||||
|
convertImageToJpeg,
|
||||||
|
convertBase64ImageToJpeg,
|
||||||
|
cleanupConvertedImage,
|
||||||
|
} from "./image-converter"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
|
||||||
|
export interface LookAtFilePart {
|
||||||
|
type: "file"
|
||||||
|
mime: string
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedLookAtInput {
|
||||||
|
readonly filePart: LookAtFilePart
|
||||||
|
readonly isBase64Input: boolean
|
||||||
|
readonly sourceDescription: string
|
||||||
|
cleanup(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrepareLookAtInputResult =
|
||||||
|
| { ok: true; value: PreparedLookAtInput }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
|
||||||
|
function getTemporaryConversionPath(error: unknown): string | null {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryOutputPath = Reflect.get(error, "temporaryOutputPath")
|
||||||
|
if (typeof temporaryOutputPath === "string" && temporaryOutputPath.length > 0) {
|
||||||
|
return temporaryOutputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryDirectory = Reflect.get(error, "temporaryDirectory")
|
||||||
|
if (typeof temporaryDirectory === "string" && temporaryDirectory.length > 0) {
|
||||||
|
return temporaryDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareLookAtInput(args: LookAtArgs): PrepareLookAtInputResult {
|
||||||
|
const imageData = args.image_data
|
||||||
|
const filePath = args.file_path
|
||||||
|
|
||||||
|
if (imageData) {
|
||||||
|
const mimeType = inferMimeTypeFromBase64(imageData)
|
||||||
|
|
||||||
|
let finalBase64Data = extractBase64Data(imageData)
|
||||||
|
let finalMimeType = mimeType
|
||||||
|
let tempFilesToCleanup: string[] = []
|
||||||
|
|
||||||
|
if (needsConversion(mimeType)) {
|
||||||
|
log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`)
|
||||||
|
try {
|
||||||
|
const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType)
|
||||||
|
finalBase64Data = base64
|
||||||
|
finalMimeType = "image/jpeg"
|
||||||
|
tempFilesToCleanup = tempFiles
|
||||||
|
log("[look_at] Base64 conversion successful")
|
||||||
|
} catch (conversionError) {
|
||||||
|
log(`[look_at] Base64 conversion failed: ${conversionError}`)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Error: Failed to convert Base64 image format. ${conversionError}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
isBase64Input: true,
|
||||||
|
sourceDescription: "clipboard/pasted image",
|
||||||
|
filePart: {
|
||||||
|
type: "file",
|
||||||
|
mime: finalMimeType,
|
||||||
|
url: `data:${finalMimeType};base64,${finalBase64Data}`,
|
||||||
|
filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`,
|
||||||
|
},
|
||||||
|
cleanup() {
|
||||||
|
for (const temporaryFile of tempFilesToCleanup) {
|
||||||
|
cleanupConvertedImage(temporaryFile)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
let mimeType = inferMimeTypeFromFilePath(filePath)
|
||||||
|
let actualFilePath = filePath
|
||||||
|
let tempConversionPath: string | null = null
|
||||||
|
|
||||||
|
if (needsConversion(mimeType)) {
|
||||||
|
log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`)
|
||||||
|
try {
|
||||||
|
const convertedFilePath = convertImageToJpeg(filePath, mimeType)
|
||||||
|
tempConversionPath = convertedFilePath
|
||||||
|
actualFilePath = convertedFilePath
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
log(`[look_at] Conversion successful: ${convertedFilePath}`)
|
||||||
|
} catch (conversionError) {
|
||||||
|
const failedConversionPath = getTemporaryConversionPath(conversionError)
|
||||||
|
if (failedConversionPath) {
|
||||||
|
tempConversionPath = failedConversionPath
|
||||||
|
}
|
||||||
|
log(`[look_at] Conversion failed: ${conversionError}`)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Error: Failed to convert image format. ${conversionError}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
isBase64Input: false,
|
||||||
|
sourceDescription: filePath,
|
||||||
|
filePart: {
|
||||||
|
type: "file",
|
||||||
|
mime: mimeType,
|
||||||
|
url: pathToFileURL(actualFilePath).href,
|
||||||
|
filename: basename(actualFilePath),
|
||||||
|
},
|
||||||
|
cleanup() {
|
||||||
|
if (tempConversionPath) {
|
||||||
|
cleanupConvertedImage(tempConversionPath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Error: Must provide either 'file_path' or 'image_data'.",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const READ_ENABLED = false
|
||||||
|
|
||||||
|
export function buildLookAtPrompt(goal: string, isBase64Input: boolean): string {
|
||||||
|
const subjectNoun = isBase64Input ? "image" : "file"
|
||||||
|
const sourceClause = READ_ENABLED
|
||||||
|
? "Use the Read tool on the provided file path to load its contents, then analyze it."
|
||||||
|
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
|
||||||
|
|
||||||
|
return `Analyze the attached ${subjectNoun} and extract the requested information.
|
||||||
|
|
||||||
|
${sourceClause}
|
||||||
|
|
||||||
|
Goal: ${goal}
|
||||||
|
|
||||||
|
Provide ONLY the extracted information that matches the goal.
|
||||||
|
Be thorough on what was requested, concise on everything else.
|
||||||
|
If the requested information is not found, clearly state what is missing.`
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
|
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||||
|
import { extractLatestAssistantText } from "./assistant-message-extractor"
|
||||||
|
import { MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||||
|
import { READ_ENABLED, buildLookAtPrompt } from "./look-at-prompt"
|
||||||
|
import type { LookAtFilePart } from "./look-at-input-preparer"
|
||||||
|
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
|
||||||
|
|
||||||
|
interface RunLookAtSessionInput {
|
||||||
|
ctx: PluginInput
|
||||||
|
toolContext: ToolContext
|
||||||
|
goal: string
|
||||||
|
filePart: LookAtFilePart
|
||||||
|
isBase64Input: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLookAtSession({
|
||||||
|
ctx,
|
||||||
|
toolContext,
|
||||||
|
goal,
|
||||||
|
filePart,
|
||||||
|
isBase64Input,
|
||||||
|
}: RunLookAtSessionInput): Promise<string> {
|
||||||
|
const prompt = buildLookAtPrompt(goal, isBase64Input)
|
||||||
|
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
|
||||||
|
|
||||||
|
log(`[look_at] Creating session with parent: ${toolContext.sessionID}`)
|
||||||
|
const parentSession = await ctx.client.session.get({
|
||||||
|
path: { id: toolContext.sessionID },
|
||||||
|
}).catch(() => null)
|
||||||
|
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
|
||||||
|
|
||||||
|
const createResult = await ctx.client.session.create({
|
||||||
|
body: {
|
||||||
|
parentID: toolContext.sessionID,
|
||||||
|
title: `look_at: ${goal.substring(0, 50)}`,
|
||||||
|
},
|
||||||
|
query: { directory: parentDirectory },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (createResult.error) {
|
||||||
|
log("[look_at] Session create error:", createResult.error)
|
||||||
|
const errorString = String(createResult.error)
|
||||||
|
if (errorString.toLowerCase().includes("unauthorized")) {
|
||||||
|
return `Error: Failed to create session (Unauthorized). This may be due to:
|
||||||
|
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
|
||||||
|
2. Provider authentication issues
|
||||||
|
3. Session permission inheritance problems
|
||||||
|
|
||||||
|
Try using a different provider or API key authentication.
|
||||||
|
|
||||||
|
Original error: ${createResult.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Error: Failed to create session: ${createResult.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = createResult.data.id
|
||||||
|
log(`[look_at] Created session: ${sessionID}`)
|
||||||
|
|
||||||
|
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||||
|
try {
|
||||||
|
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
|
agent: MULTIMODAL_LOOKER_AGENT,
|
||||||
|
tools: {
|
||||||
|
task: false,
|
||||||
|
call_omo_agent: false,
|
||||||
|
look_at: false,
|
||||||
|
read: READ_ENABLED,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
filePart,
|
||||||
|
],
|
||||||
|
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
|
||||||
|
...(agentVariant ? { variant: agentVariant } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (promptError) {
|
||||||
|
log("[look_at] Prompt error (ignored, will still fetch messages):", promptError)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[look_at] Fetching messages from session ${sessionID}...`)
|
||||||
|
const messagesResult = await ctx.client.session.messages({
|
||||||
|
path: { id: sessionID },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (messagesResult.error) {
|
||||||
|
log("[look_at] Messages error:", messagesResult.error)
|
||||||
|
return `Error: Failed to get messages: ${messagesResult.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = messagesResult.data
|
||||||
|
log(`[look_at] Got ${messages.length} messages`)
|
||||||
|
|
||||||
|
const responseText = extractLatestAssistantText(messages)
|
||||||
|
if (!responseText) {
|
||||||
|
log("[look_at] No assistant message found")
|
||||||
|
return "Error: No response from multimodal-looker agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[look_at] Got response, length: ${responseText.length}`)
|
||||||
|
return responseText
|
||||||
|
}
|
||||||
+18
-209
@@ -1,43 +1,11 @@
|
|||||||
import { basename } from "node:path"
|
|
||||||
import { pathToFileURL } from "node:url"
|
|
||||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
import { LOOK_AT_DESCRIPTION } from "./constants"
|
||||||
import type { LookAtArgs } from "./types"
|
import type { LookAtArgs } from "./types"
|
||||||
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { extractLatestAssistantText } from "./assistant-message-extractor"
|
|
||||||
import type { LookAtArgsWithAlias } from "./look-at-arguments"
|
import type { LookAtArgsWithAlias } from "./look-at-arguments"
|
||||||
import { normalizeArgs, validateArgs } from "./look-at-arguments"
|
import { normalizeArgs, validateArgs } from "./look-at-arguments"
|
||||||
import {
|
import { prepareLookAtInput } from "./look-at-input-preparer"
|
||||||
extractBase64Data,
|
import { runLookAtSession } from "./look-at-session-runner"
|
||||||
inferMimeTypeFromBase64,
|
|
||||||
inferMimeTypeFromFilePath,
|
|
||||||
} from "./mime-type-inference"
|
|
||||||
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
|
|
||||||
import {
|
|
||||||
needsConversion,
|
|
||||||
convertImageToJpeg,
|
|
||||||
convertBase64ImageToJpeg,
|
|
||||||
cleanupConvertedImage,
|
|
||||||
} from "./image-converter"
|
|
||||||
|
|
||||||
function getTemporaryConversionPath(error: unknown): string | null {
|
|
||||||
if (!(error instanceof Error)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const temporaryOutputPath = Reflect.get(error, "temporaryOutputPath")
|
|
||||||
if (typeof temporaryOutputPath === "string" && temporaryOutputPath.length > 0) {
|
|
||||||
return temporaryOutputPath
|
|
||||||
}
|
|
||||||
|
|
||||||
const temporaryDirectory = Reflect.get(error, "temporaryDirectory")
|
|
||||||
if (typeof temporaryDirectory === "string" && temporaryDirectory.length > 0) {
|
|
||||||
return temporaryDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export { normalizeArgs, validateArgs } from "./look-at-arguments"
|
export { normalizeArgs, validateArgs } from "./look-at-arguments"
|
||||||
|
|
||||||
@@ -57,188 +25,29 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
|
|||||||
return validationError
|
return validationError
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBase64Input = Boolean(args.image_data)
|
const preparedInputResult = prepareLookAtInput(args)
|
||||||
const sourceDescription = isBase64Input ? "clipboard/pasted image" : args.file_path
|
if (!preparedInputResult.ok) {
|
||||||
|
return preparedInputResult.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedInput = preparedInputResult.value
|
||||||
|
const { isBase64Input, sourceDescription } = preparedInput
|
||||||
log(`[look_at] Analyzing ${sourceDescription}, goal: ${args.goal}`)
|
log(`[look_at] Analyzing ${sourceDescription}, goal: ${args.goal}`)
|
||||||
|
|
||||||
const imageData = args.image_data
|
|
||||||
const filePath = args.file_path
|
|
||||||
|
|
||||||
let mimeType: string
|
|
||||||
let filePart: { type: "file"; mime: string; url: string; filename: string }
|
|
||||||
let tempFilePath: string | null = null
|
|
||||||
let tempConversionPath: string | null = null
|
|
||||||
let tempFilesToCleanup: string[] = []
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (imageData) {
|
return await runLookAtSession({
|
||||||
mimeType = inferMimeTypeFromBase64(imageData)
|
ctx,
|
||||||
|
toolContext,
|
||||||
let finalBase64Data = extractBase64Data(imageData)
|
goal: args.goal,
|
||||||
let finalMimeType = mimeType
|
filePart: preparedInput.filePart,
|
||||||
|
isBase64Input,
|
||||||
if (needsConversion(mimeType)) {
|
|
||||||
log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`)
|
|
||||||
try {
|
|
||||||
const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType)
|
|
||||||
finalBase64Data = base64
|
|
||||||
finalMimeType = "image/jpeg"
|
|
||||||
tempFilesToCleanup = tempFiles
|
|
||||||
log(`[look_at] Base64 conversion successful`)
|
|
||||||
} catch (conversionError) {
|
|
||||||
log(`[look_at] Base64 conversion failed: ${conversionError}`)
|
|
||||||
return `Error: Failed to convert Base64 image format. ${conversionError}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filePart = {
|
|
||||||
type: "file",
|
|
||||||
mime: finalMimeType,
|
|
||||||
url: `data:${finalMimeType};base64,${finalBase64Data}`,
|
|
||||||
filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`,
|
|
||||||
}
|
|
||||||
} else if (filePath) {
|
|
||||||
mimeType = inferMimeTypeFromFilePath(filePath)
|
|
||||||
|
|
||||||
let actualFilePath = filePath
|
|
||||||
if (needsConversion(mimeType)) {
|
|
||||||
log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`)
|
|
||||||
try {
|
|
||||||
tempFilePath = convertImageToJpeg(filePath, mimeType)
|
|
||||||
tempConversionPath = tempFilePath
|
|
||||||
actualFilePath = tempFilePath
|
|
||||||
mimeType = "image/jpeg"
|
|
||||||
log(`[look_at] Conversion successful: ${tempFilePath}`)
|
|
||||||
} catch (conversionError) {
|
|
||||||
const failedConversionPath = getTemporaryConversionPath(conversionError)
|
|
||||||
if (failedConversionPath) {
|
|
||||||
tempConversionPath = failedConversionPath
|
|
||||||
}
|
|
||||||
log(`[look_at] Conversion failed: ${conversionError}`)
|
|
||||||
return `Error: Failed to convert image format. ${conversionError}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filePart = {
|
|
||||||
type: "file",
|
|
||||||
mime: mimeType,
|
|
||||||
url: pathToFileURL(actualFilePath).href,
|
|
||||||
filename: basename(actualFilePath),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "Error: Must provide either 'file_path' or 'image_data'."
|
|
||||||
}
|
|
||||||
|
|
||||||
const readEnabled = false
|
|
||||||
const subjectNoun = isBase64Input ? "image" : "file"
|
|
||||||
const sourceClause = readEnabled
|
|
||||||
? `Use the Read tool on the provided file path to load its contents, then analyze it.`
|
|
||||||
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
|
|
||||||
|
|
||||||
const prompt = `Analyze the attached ${subjectNoun} and extract the requested information.
|
|
||||||
|
|
||||||
${sourceClause}
|
|
||||||
|
|
||||||
Goal: ${args.goal}
|
|
||||||
|
|
||||||
Provide ONLY the extracted information that matches the goal.
|
|
||||||
Be thorough on what was requested, concise on everything else.
|
|
||||||
If the requested information is not found, clearly state what is missing.`
|
|
||||||
|
|
||||||
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
|
|
||||||
|
|
||||||
log(`[look_at] Creating session with parent: ${toolContext.sessionID}`)
|
|
||||||
const parentSession = await ctx.client.session.get({
|
|
||||||
path: { id: toolContext.sessionID },
|
|
||||||
}).catch(() => null)
|
|
||||||
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
|
|
||||||
|
|
||||||
const createResult = await ctx.client.session.create({
|
|
||||||
body: {
|
|
||||||
parentID: toolContext.sessionID,
|
|
||||||
title: `look_at: ${args.goal.substring(0, 50)}`,
|
|
||||||
},
|
|
||||||
query: { directory: parentDirectory },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (createResult.error) {
|
|
||||||
log(`[look_at] Session create error:`, createResult.error)
|
|
||||||
const errorStr = String(createResult.error)
|
|
||||||
if (errorStr.toLowerCase().includes("unauthorized")) {
|
|
||||||
return `Error: Failed to create session (Unauthorized). This may be due to:
|
|
||||||
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
|
|
||||||
2. Provider authentication issues
|
|
||||||
3. Session permission inheritance problems
|
|
||||||
|
|
||||||
Try using a different provider or API key authentication.
|
|
||||||
|
|
||||||
Original error: ${createResult.error}`
|
|
||||||
}
|
|
||||||
return `Error: Failed to create session: ${createResult.error}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionID = createResult.data.id
|
|
||||||
log(`[look_at] Created session: ${sessionID}`)
|
|
||||||
|
|
||||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
|
||||||
try {
|
|
||||||
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: {
|
|
||||||
agent: MULTIMODAL_LOOKER_AGENT,
|
|
||||||
tools: {
|
|
||||||
task: false,
|
|
||||||
call_omo_agent: false,
|
|
||||||
look_at: false,
|
|
||||||
read: readEnabled,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: prompt },
|
|
||||||
filePart,
|
|
||||||
],
|
|
||||||
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
|
|
||||||
...(agentVariant ? { variant: agentVariant } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} catch (promptError) {
|
|
||||||
log(`[look_at] Prompt error (ignored, will still fetch messages):`, promptError)
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[look_at] Fetching messages from session ${sessionID}...`)
|
|
||||||
|
|
||||||
const messagesResult = await ctx.client.session.messages({
|
|
||||||
path: { id: sessionID },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (messagesResult.error) {
|
|
||||||
log(`[look_at] Messages error:`, messagesResult.error)
|
|
||||||
return `Error: Failed to get messages: ${messagesResult.error}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = messagesResult.data
|
|
||||||
log(`[look_at] Got ${messages.length} messages`)
|
|
||||||
|
|
||||||
const responseText = extractLatestAssistantText(messages)
|
|
||||||
if (!responseText) {
|
|
||||||
log("[look_at] No assistant message found")
|
|
||||||
return "Error: No response from multimodal-looker agent"
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[look_at] Got response, length: ${responseText.length}`)
|
|
||||||
return responseText
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
log(`[look_at] Unexpected error analyzing ${sourceDescription}:`, error)
|
log(`[look_at] Unexpected error analyzing ${sourceDescription}:`, error)
|
||||||
return `Error: Failed to analyze ${sourceDescription}: ${errorMessage}`
|
return `Error: Failed to analyze ${sourceDescription}: ${errorMessage}`
|
||||||
} finally {
|
} finally {
|
||||||
if (tempConversionPath) {
|
preparedInput.cleanup()
|
||||||
cleanupConvertedImage(tempConversionPath)
|
|
||||||
} else if (tempFilePath) {
|
|
||||||
cleanupConvertedImage(tempFilePath)
|
|
||||||
}
|
|
||||||
tempFilesToCleanup.forEach(file => {
|
|
||||||
cleanupConvertedImage(file)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export function parseSkillMcpArguments(
|
||||||
|
argsJson: string | Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!argsJson) return {}
|
||||||
|
if (typeof argsJson === "object" && argsJson !== null) {
|
||||||
|
return argsJson
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonString = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
|
||||||
|
const parsed = JSON.parse(jsonString)
|
||||||
|
if (typeof parsed !== "object" || parsed === null) {
|
||||||
|
throw new Error("Arguments must be a JSON object")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as Record<string, unknown>
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new Error(
|
||||||
|
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
||||||
|
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
||||||
|
`Received: ${argsJson}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants"
|
import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants"
|
||||||
|
import { parseSkillMcpArguments } from "./parse-skill-mcp-arguments"
|
||||||
import type { SkillMcpArgs } from "./types"
|
import type { SkillMcpArgs } from "./types"
|
||||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||||
@@ -82,30 +83,6 @@ function formatBuiltinMcpHint(mcpName: string): string | null {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArguments(argsJson: string | Record<string, unknown> | undefined): Record<string, unknown> {
|
|
||||||
if (!argsJson) return {}
|
|
||||||
if (typeof argsJson === "object" && argsJson !== null) {
|
|
||||||
return argsJson
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Strip outer single quotes if present (common in LLM output)
|
|
||||||
const jsonStr = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonStr)
|
|
||||||
if (typeof parsed !== "object" || parsed === null) {
|
|
||||||
throw new Error("Arguments must be a JSON object")
|
|
||||||
}
|
|
||||||
return parsed as Record<string, unknown>
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
||||||
throw new Error(
|
|
||||||
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
|
||||||
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
|
||||||
`Received: ${argsJson}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyGrepFilter(output: string, pattern: string | undefined): string {
|
export function applyGrepFilter(output: string, pattern: string | undefined): string {
|
||||||
if (!pattern) return output
|
if (!pattern) return output
|
||||||
try {
|
try {
|
||||||
@@ -174,7 +151,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
|
|||||||
skillName: found.skill.name,
|
skillName: found.skill.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedArgs = parseArguments(args.arguments)
|
const parsedArgs = parseSkillMcpArguments(args.arguments)
|
||||||
|
|
||||||
let output: string
|
let output: string
|
||||||
switch (operation.type) {
|
switch (operation.type) {
|
||||||
|
|||||||
Reference in New Issue
Block a user