Merge branch 'dev' into fix/issue-2232
This commit is contained in:
@@ -158,6 +158,13 @@ export function createChatMessageHandler(args: {
|
||||
}
|
||||
}
|
||||
|
||||
applyUltraworkModelOverrideOnMessage(pluginConfig, input.agent, output, pluginContext.client.tui, input.sessionID)
|
||||
await applyUltraworkModelOverrideOnMessage(
|
||||
pluginConfig,
|
||||
input.agent,
|
||||
output,
|
||||
pluginContext.client.tui,
|
||||
input.sessionID,
|
||||
pluginContext.client,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { afterEach, describe, expect, mock, test } = require("bun:test")
|
||||
|
||||
mock.module("../shared/connected-providers-cache", () => ({
|
||||
readConnectedProvidersCache: () => null,
|
||||
readProviderModelsCache: () => null,
|
||||
}))
|
||||
|
||||
import { createEventHandler } from "./event"
|
||||
import { createChatMessageHandler } from "./chat-message"
|
||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||
|
||||
describe("createEventHandler - model fallback", () => {
|
||||
const createHandler = (args?: { hooks?: any }) => {
|
||||
const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
|
||||
const abortCalls: string[] = []
|
||||
const promptCalls: string[] = []
|
||||
|
||||
@@ -26,7 +31,7 @@ describe("createEventHandler - model fallback", () => {
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
pluginConfig: (args?.pluginConfig ?? {}) as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
@@ -206,11 +211,222 @@ describe("createEventHandler - model fallback", () => {
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID])
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
expect(output.message["model"]).toMatchObject({
|
||||
providerID: "kimi-for-coding",
|
||||
modelID: "k2p5",
|
||||
})
|
||||
expect(output.message["variant"]).toBe("max")
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not spam abort/prompt when session.status retry countdown updates", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_status_retry_dedup"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_status_dedup",
|
||||
sessionID,
|
||||
role: "user",
|
||||
modelID: "claude-opus-4-6-thinking",
|
||||
providerID: "anthropic",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
next: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~4 days attempt #1]",
|
||||
next: 299,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID])
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
})
|
||||
|
||||
test("does not trigger model-fallback from session.status when runtime_fallback is enabled", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_status_retry_runtime_enabled"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const runtimeFallback = {
|
||||
event: async () => {},
|
||||
"chat.message": async () => {},
|
||||
}
|
||||
const { handler, abortCalls, promptCalls } = createHandler({
|
||||
hooks: { modelFallback, runtimeFallback },
|
||||
pluginConfig: { runtime_fallback: { enabled: true } },
|
||||
})
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_status_runtime_enabled",
|
||||
sessionID,
|
||||
role: "user",
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "quotio",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
|
||||
next: 476,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([])
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("prefers user-configured fallback_models over hardcoded chain on session.status retry", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_status_retry_user_fallback"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const pluginConfig = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
fallback_models: ["quotio/gpt-5.2", "quotio/kimi-k2.5"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig })
|
||||
|
||||
const chatMessageHandler = createChatMessageHandler({
|
||||
ctx: {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
shouldOverride: () => false,
|
||||
markApplied: () => {},
|
||||
},
|
||||
hooks: {
|
||||
modelFallback,
|
||||
stopContinuationGuard: null,
|
||||
keywordDetector: null,
|
||||
claudeCodeHooks: null,
|
||||
autoSlashCommand: null,
|
||||
startWork: null,
|
||||
ralphLoop: null,
|
||||
} as any,
|
||||
})
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_status_user_fallback",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
content: [],
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "quotio",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
next: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
||||
await chatMessageHandler(
|
||||
{
|
||||
sessionID,
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
output,
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID])
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "quotio",
|
||||
modelID: "gpt-5.2",
|
||||
})
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => {
|
||||
@@ -322,21 +538,21 @@ describe("createEventHandler - model fallback", () => {
|
||||
//#when - first retry cycle
|
||||
const first = await triggerRetryCycle()
|
||||
|
||||
//#then - first fallback entry applied (prefers current provider when available)
|
||||
expect(first.message["model"]).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
//#then - first fallback entry applied (no-op skip: claude-opus-4-6 matches current model after normalization)
|
||||
expect(first.message["model"]).toMatchObject({
|
||||
providerID: "kimi-for-coding",
|
||||
modelID: "k2p5",
|
||||
})
|
||||
expect(first.message["variant"]).toBe("max")
|
||||
expect(first.message["variant"]).toBeUndefined()
|
||||
|
||||
//#when - second retry cycle
|
||||
const second = await triggerRetryCycle()
|
||||
|
||||
//#then - second fallback entry applied (chain advanced)
|
||||
expect(second.message["model"]).toEqual({
|
||||
providerID: "kimi-for-coding",
|
||||
modelID: "k2p5",
|
||||
//#then - second fallback entry applied (chain advanced past k2p5)
|
||||
expect(second.message["model"]).toMatchObject({
|
||||
modelID: "kimi-k2.5",
|
||||
})
|
||||
expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy()
|
||||
expect(second.message["variant"]).toBeUndefined()
|
||||
expect(abortCalls).toEqual([sessionID, sessionID])
|
||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||
|
||||
+61
-28
@@ -13,11 +13,16 @@ import {
|
||||
import {
|
||||
clearPendingModelFallback,
|
||||
clearSessionFallbackChain,
|
||||
setSessionFallbackChain,
|
||||
setPendingModelFallback,
|
||||
} from "../hooks/model-fallback/hook";
|
||||
import { getFallbackModelsForSession } from "../hooks/runtime-fallback/fallback-models";
|
||||
import { resetMessageCursor } from "../shared";
|
||||
import { getAgentConfigKey } from "../shared/agent-display-names";
|
||||
import { log } from "../shared/logger";
|
||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
||||
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||
import { lspManager } from "../tools";
|
||||
@@ -97,6 +102,22 @@ function extractProviderModelFromErrorMessage(message: string): { providerID?: s
|
||||
|
||||
return {};
|
||||
}
|
||||
function applyUserConfiguredFallbackChain(
|
||||
sessionID: string,
|
||||
agentName: string,
|
||||
currentProviderID: string,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
): void {
|
||||
const agentKey = getAgentConfigKey(agentName);
|
||||
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
|
||||
if (configuredFallbackModels.length === 0) return;
|
||||
|
||||
const fallbackChain = buildFallbackChainFromModels(configuredFallbackModels, currentProviderID);
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
setSessionFallbackChain(sessionID, fallbackChain);
|
||||
}
|
||||
}
|
||||
|
||||
function isCompactionAgent(agent: string): boolean {
|
||||
return agent.toLowerCase() === "compaction";
|
||||
@@ -116,6 +137,11 @@ export function createEventHandler(args: {
|
||||
client: {
|
||||
session: {
|
||||
abort: (input: { path: { id: string } }) => Promise<unknown>;
|
||||
promptAsync?: (input: {
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: "text"; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
prompt: (input: {
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: "text"; text: string }> };
|
||||
@@ -177,6 +203,29 @@ export function createEventHandler(args: {
|
||||
return !subagentSessions.has(sessionID);
|
||||
};
|
||||
|
||||
const autoContinueAfterFallback = async (sessionID: string, source: string): Promise<void> => {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => {
|
||||
log("[event] model-fallback abort failed", { sessionID, source, error });
|
||||
});
|
||||
|
||||
const promptBody = {
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text" as const, text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
};
|
||||
|
||||
if (typeof pluginContext.client.session.promptAsync === "function") {
|
||||
await pluginContext.client.session.promptAsync(promptBody).catch((error) => {
|
||||
log("[event] model-fallback promptAsync failed", { sessionID, source, error });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await pluginContext.client.session.prompt(promptBody).catch((error) => {
|
||||
log("[event] model-fallback prompt failed", { sessionID, source, error });
|
||||
});
|
||||
};
|
||||
|
||||
return async (input): Promise<void> => {
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
@@ -311,6 +360,7 @@ export function createEventHandler(args: {
|
||||
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
|
||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
|
||||
const currentModel = normalizeFallbackModelID(rawModel);
|
||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
@@ -320,15 +370,7 @@ export function createEventHandler(args: {
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
|
||||
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
await autoContinueAfterFallback(sessionID, "message.updated");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,10 +385,14 @@ export function createEventHandler(args: {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
|
||||
|
||||
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) {
|
||||
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
|
||||
try {
|
||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
|
||||
const parsedForKey = extractProviderModelFromErrorMessage(retryMessage);
|
||||
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage);
|
||||
// Deduplicate countdown updates for the same retry attempt/model.
|
||||
// Messages like "retrying in 7m 56s" change every second but should only trigger once.
|
||||
const retryKey = `${retryAttempt}:${parsedForKey.providerID ?? ""}/${parsedForKey.modelID ?? ""}:${normalizeRetryStatusMessage(retryMessage)}`;
|
||||
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
|
||||
return;
|
||||
}
|
||||
@@ -371,6 +417,7 @@ export function createEventHandler(args: {
|
||||
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
|
||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
|
||||
currentModel = normalizeFallbackModelID(currentModel);
|
||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
@@ -379,14 +426,7 @@ export function createEventHandler(args: {
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
await autoContinueAfterFallback(sessionID, "session.status");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,6 +489,7 @@ export function createEventHandler(args: {
|
||||
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
|
||||
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
|
||||
currentModel = normalizeFallbackModelID(currentModel);
|
||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
@@ -457,15 +498,7 @@ export function createEventHandler(args: {
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
await autoContinueAfterFallback(sessionID, "session.error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { cpSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
|
||||
const tempDirectories: string[] = []
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getNestedRecord(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
||||
const value = record[key]
|
||||
return isRecord(value) ? value : undefined
|
||||
}
|
||||
|
||||
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
|
||||
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
|
||||
const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-"))
|
||||
const copiedZodDirectory = join(tempDirectory, "zod")
|
||||
|
||||
cpSync(sourceZodDirectory, copiedZodDirectory, { recursive: true })
|
||||
tempDirectories.push(tempDirectory)
|
||||
|
||||
return await import(pathToFileURL(join(copiedZodDirectory, "index.js")).href)
|
||||
}
|
||||
|
||||
function serializeWithHostZod(
|
||||
hostZod: typeof import("zod"),
|
||||
args: Record<string, object>,
|
||||
): Record<string, unknown> {
|
||||
return hostZod.z.toJSONSchema(Reflect.apply(hostZod.z.object, hostZod.z, [args]))
|
||||
}
|
||||
|
||||
describe("normalizeToolArgSchemas", () => {
|
||||
afterEach(() => {
|
||||
for (const tempDirectory of tempDirectories.splice(0)) {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves nested descriptions and metadata across zod instances", async () => {
|
||||
// given
|
||||
const hostZod = await loadSeparateHostZodModule()
|
||||
const toolDefinition = tool({
|
||||
description: "Search tool",
|
||||
args: {
|
||||
filters: tool.schema
|
||||
.object({
|
||||
query: tool.schema
|
||||
.string()
|
||||
.describe("Free-text search query")
|
||||
.meta({ title: "Query", examples: ["issue 2314"] }),
|
||||
})
|
||||
.describe("Filter options")
|
||||
.meta({ title: "Filters" }),
|
||||
},
|
||||
async execute(): Promise<string> {
|
||||
return "ok"
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const beforeSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const beforeProperties = getNestedRecord(beforeSchema, "properties")
|
||||
const beforeFilters = beforeProperties ? getNestedRecord(beforeProperties, "filters") : undefined
|
||||
const beforeFilterProperties = beforeFilters ? getNestedRecord(beforeFilters, "properties") : undefined
|
||||
const beforeQuery = beforeFilterProperties ? getNestedRecord(beforeFilterProperties, "query") : undefined
|
||||
|
||||
normalizeToolArgSchemas(toolDefinition)
|
||||
|
||||
const afterSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const afterProperties = getNestedRecord(afterSchema, "properties")
|
||||
const afterFilters = afterProperties ? getNestedRecord(afterProperties, "filters") : undefined
|
||||
const afterFilterProperties = afterFilters ? getNestedRecord(afterFilters, "properties") : undefined
|
||||
const afterQuery = afterFilterProperties ? getNestedRecord(afterFilterProperties, "query") : undefined
|
||||
|
||||
// then
|
||||
expect(beforeFilters?.description).toBeUndefined()
|
||||
expect(beforeFilters?.title).toBeUndefined()
|
||||
expect(beforeQuery?.description).toBeUndefined()
|
||||
expect(beforeQuery?.title).toBeUndefined()
|
||||
expect(beforeQuery?.examples).toBeUndefined()
|
||||
|
||||
expect(afterFilters?.description).toBe("Filter options")
|
||||
expect(afterFilters?.title).toBe("Filters")
|
||||
expect(afterQuery?.description).toBe("Free-text search query")
|
||||
expect(afterQuery?.title).toBe("Query")
|
||||
expect(afterQuery?.examples).toEqual(["issue 2314"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
|
||||
type ToolArgSchema = ToolDefinition["args"][string]
|
||||
|
||||
type SchemaWithJsonSchemaOverride = ToolArgSchema & {
|
||||
_zod: ToolArgSchema["_zod"] & {
|
||||
toJSONSchema?: () => unknown
|
||||
}
|
||||
}
|
||||
|
||||
function stripRootJsonSchemaFields(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _schema, ...rest } = jsonSchema
|
||||
return rest
|
||||
}
|
||||
|
||||
function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
|
||||
if (schema._zod.toJSONSchema) {
|
||||
return
|
||||
}
|
||||
|
||||
schema._zod.toJSONSchema = (): Record<string, unknown> => {
|
||||
const originalOverride = schema._zod.toJSONSchema
|
||||
delete schema._zod.toJSONSchema
|
||||
|
||||
try {
|
||||
return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema))
|
||||
} finally {
|
||||
schema._zod.toJSONSchema = originalOverride
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeToolArgSchemas<TDefinition extends Pick<ToolDefinition, "args">>(
|
||||
toolDefinition: TDefinition,
|
||||
): TDefinition {
|
||||
for (const schema of Object.values(toolDefinition.args)) {
|
||||
attachJsonSchemaOverride(schema)
|
||||
}
|
||||
|
||||
return toolDefinition
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { OhMyOpenCodeConfigSchema } from "../config"
|
||||
import * as mcpLoader from "../features/claude-code-mcp-loader"
|
||||
import * as skillLoader from "../features/opencode-skill-loader"
|
||||
import { createSkillContext } from "./skill-context"
|
||||
|
||||
describe("createSkillContext", () => {
|
||||
const testDirectory = join(tmpdir(), `skill-context-test-${Date.now()}`)
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("excludes discovered playwright skill when browser provider is agent-browser", async () => {
|
||||
// given
|
||||
const discoveredPlaywrightDir = join(testDirectory, ".claude", "skills", "playwright")
|
||||
mkdirSync(discoveredPlaywrightDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(discoveredPlaywrightDir, "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: playwright",
|
||||
"description: Discovered playwright skill",
|
||||
"---",
|
||||
"Discovered playwright body.",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const discoverConfigSourceSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverConfigSourceSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverUserClaudeSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverUserClaudeSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverOpencodeGlobalSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverOpencodeGlobalSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverProjectAgentsSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverProjectAgentsSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverGlobalAgentsSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverGlobalAgentsSkills",
|
||||
).mockResolvedValue([])
|
||||
const getSystemMcpServerNamesSpy = spyOn(
|
||||
mcpLoader,
|
||||
"getSystemMcpServerNames",
|
||||
).mockReturnValue(new Set<string>())
|
||||
|
||||
const pluginConfig = OhMyOpenCodeConfigSchema.parse({
|
||||
browser_automation_engine: { provider: "agent-browser" },
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
const result = await createSkillContext({
|
||||
directory: testDirectory,
|
||||
pluginConfig,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.browserProvider).toBe("agent-browser")
|
||||
expect(result.mergedSkills.some((skill) => skill.name === "agent-browser")).toBe(true)
|
||||
expect(result.mergedSkills.some((skill) => skill.name === "playwright")).toBe(false)
|
||||
expect(result.availableSkills.some((skill) => skill.name === "playwright")).toBe(false)
|
||||
} finally {
|
||||
discoverConfigSourceSkillsSpy.mockRestore()
|
||||
discoverUserClaudeSkillsSpy.mockRestore()
|
||||
discoverOpencodeGlobalSkillsSpy.mockRestore()
|
||||
discoverProjectAgentsSkillsSpy.mockRestore()
|
||||
discoverGlobalAgentsSkillsSpy.mockRestore()
|
||||
getSystemMcpServerNamesSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -26,12 +26,27 @@ export type SkillContext = {
|
||||
disabledSkills: Set<string>
|
||||
}
|
||||
|
||||
const PROVIDER_GATED_SKILL_NAMES = new Set(["agent-browser", "playwright"])
|
||||
|
||||
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
|
||||
if (scope === "user" || scope === "opencode") return "user"
|
||||
if (scope === "project" || scope === "opencode-project") return "project"
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
function filterProviderGatedSkills(
|
||||
skills: LoadedSkill[],
|
||||
browserProvider: BrowserAutomationProvider,
|
||||
): LoadedSkill[] {
|
||||
return skills.filter((skill) => {
|
||||
if (!PROVIDER_GATED_SKILL_NAMES.has(skill.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return skill.name === browserProvider
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSkillContext(args: {
|
||||
directory: string
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
@@ -71,14 +86,34 @@ export async function createSkillContext(args: {
|
||||
discoverGlobalAgentsSkills(),
|
||||
])
|
||||
|
||||
const filteredConfigSourceSkills = filterProviderGatedSkills(
|
||||
configSourceSkills,
|
||||
browserProvider,
|
||||
)
|
||||
const filteredUserSkills = filterProviderGatedSkills(userSkills, browserProvider)
|
||||
const filteredGlobalSkills = filterProviderGatedSkills(globalSkills, browserProvider)
|
||||
const filteredProjectSkills = filterProviderGatedSkills(projectSkills, browserProvider)
|
||||
const filteredOpencodeProjectSkills = filterProviderGatedSkills(
|
||||
opencodeProjectSkills,
|
||||
browserProvider,
|
||||
)
|
||||
const filteredAgentsProjectSkills = filterProviderGatedSkills(
|
||||
agentsProjectSkills,
|
||||
browserProvider,
|
||||
)
|
||||
const filteredAgentsGlobalSkills = filterProviderGatedSkills(
|
||||
agentsGlobalSkills,
|
||||
browserProvider,
|
||||
)
|
||||
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
pluginConfig.skills,
|
||||
configSourceSkills,
|
||||
[...userSkills, ...agentsGlobalSkills],
|
||||
globalSkills,
|
||||
[...projectSkills, ...agentsProjectSkills],
|
||||
opencodeProjectSkills,
|
||||
filteredConfigSourceSkills,
|
||||
[...filteredUserSkills, ...filteredAgentsGlobalSkills],
|
||||
filteredGlobalSkills,
|
||||
[...filteredProjectSkills, ...filteredAgentsProjectSkills],
|
||||
filteredOpencodeProjectSkills,
|
||||
{ configDir: directory },
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createToolExecuteAfterHandler } from "./tool-execute-after"
|
||||
|
||||
describe("createToolExecuteAfterHandler", () => {
|
||||
it("#given truncator changes output #when tool.execute.after runs #then claudeCodeHooks receives truncated output", async () => {
|
||||
const callOrder: string[] = []
|
||||
let claudeSawOutput = ""
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
hooks: {
|
||||
toolOutputTruncator: {
|
||||
"tool.execute.after": async (_input, output) => {
|
||||
callOrder.push("truncator")
|
||||
output.output = "truncated output"
|
||||
},
|
||||
},
|
||||
claudeCodeHooks: {
|
||||
"tool.execute.after": async (_input, output) => {
|
||||
callOrder.push("claude")
|
||||
claudeSawOutput = output.output
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
await handler(
|
||||
{ tool: "hashline_edit", sessionID: "ses_test", callID: "call_test" },
|
||||
{ title: "result", output: "original output", metadata: {} }
|
||||
)
|
||||
|
||||
expect(callOrder).toEqual(["truncator", "claude"])
|
||||
expect(claudeSawOutput).toBe("truncated output")
|
||||
})
|
||||
})
|
||||
@@ -56,8 +56,8 @@ export function createToolExecuteAfterHandler(args: {
|
||||
}
|
||||
}
|
||||
|
||||
await hooks.claudeCodeHooks?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.toolOutputTruncator?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.claudeCodeHooks?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.preemptiveCompaction?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.contextWindowMonitor?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.commentChecker?.["tool.execute.after"]?.(input, output)
|
||||
|
||||
@@ -32,6 +32,7 @@ import { log } from "../shared"
|
||||
|
||||
import type { Managers } from "../create-managers"
|
||||
import type { SkillContext } from "./skill-context"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
|
||||
export type ToolRegistryResult = {
|
||||
filteredTools: ToolsRecord
|
||||
@@ -48,7 +49,13 @@ export function createToolRegistry(args: {
|
||||
const { ctx, pluginConfig, managers, skillContext, availableCategories } = args
|
||||
|
||||
const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client)
|
||||
const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager, pluginConfig.disabled_agents ?? [])
|
||||
const callOmoAgent = createCallOmoAgent(
|
||||
ctx,
|
||||
managers.backgroundManager,
|
||||
pluginConfig.disabled_agents ?? [],
|
||||
pluginConfig.agents,
|
||||
pluginConfig.categories,
|
||||
)
|
||||
|
||||
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
||||
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||
@@ -139,6 +146,10 @@ export function createToolRegistry(args: {
|
||||
...hashlineToolsRecord,
|
||||
}
|
||||
|
||||
for (const toolDefinition of Object.values(allTools)) {
|
||||
normalizeToolArgSchemas(toolDefinition)
|
||||
}
|
||||
|
||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getSessionAgent } from "../features/claude-code-session-state"
|
||||
import { log } from "../shared"
|
||||
import { getAgentConfigKey } from "../shared/agent-display-names"
|
||||
import { scheduleDeferredModelOverride } from "./ultrawork-db-model-override"
|
||||
import { resolveValidUltraworkVariant } from "./ultrawork-variant-availability"
|
||||
|
||||
const CODE_BLOCK = /```[\s\S]*?```/g
|
||||
const INLINE_CODE = /`[^`]+`/g
|
||||
@@ -15,7 +16,7 @@ export function detectUltrawork(text: string): boolean {
|
||||
}
|
||||
|
||||
function extractPromptText(parts: Array<{ type: string; text?: string }>): string {
|
||||
return parts.filter((p) => p.type === "text").map((p) => p.text || "").join("")
|
||||
return parts.filter((part) => part.type === "text").map((part) => part.text || "").join("")
|
||||
}
|
||||
|
||||
type ToastFn = {
|
||||
@@ -36,22 +37,26 @@ export type UltraworkOverrideResult = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
function isSameModel(
|
||||
current: unknown,
|
||||
target: { providerID: string; modelID: string },
|
||||
): boolean {
|
||||
if (typeof current !== "object" || current === null) return false
|
||||
const currentRecord = current as Record<string, unknown>
|
||||
return (
|
||||
currentRecord["providerID"] === target.providerID
|
||||
&& currentRecord["modelID"] === target.modelID
|
||||
)
|
||||
type ModelDescriptor = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
function isSameModel(current: unknown, target: ModelDescriptor): boolean {
|
||||
if (typeof current !== "object" || current === null) return false
|
||||
const currentRecord = current as Record<string, unknown>
|
||||
return currentRecord["providerID"] === target.providerID && currentRecord["modelID"] === target.modelID
|
||||
}
|
||||
|
||||
function getMessageModel(current: unknown): ModelDescriptor | undefined {
|
||||
if (typeof current !== "object" || current === null) return undefined
|
||||
const currentRecord = current as Record<string, unknown>
|
||||
const providerID = currentRecord["providerID"]
|
||||
const modelID = currentRecord["modelID"]
|
||||
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ultrawork model override config for the given agent and prompt text.
|
||||
* Returns null if no override should be applied.
|
||||
*/
|
||||
export function resolveUltraworkOverride(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
inputAgentName: string | undefined,
|
||||
@@ -76,9 +81,7 @@ export function resolveUltraworkOverride(
|
||||
if (!ultraworkConfig?.model && !ultraworkConfig?.variant) return null
|
||||
|
||||
if (!ultraworkConfig.model) {
|
||||
return {
|
||||
variant: ultraworkConfig.variant,
|
||||
}
|
||||
return { variant: ultraworkConfig.variant }
|
||||
}
|
||||
|
||||
const modelParts = ultraworkConfig.model.split("/")
|
||||
@@ -91,37 +94,20 @@ export function resolveUltraworkOverride(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ultrawork model override using a deferred DB update strategy.
|
||||
*
|
||||
* Instead of directly mutating output.message.model (which would cause the TUI
|
||||
* bottom bar to show the override model), this schedules a queueMicrotask that
|
||||
* updates the message model directly in SQLite AFTER Session.updateMessage()
|
||||
* saves the original model, but BEFORE loop() reads it for the API call.
|
||||
*
|
||||
* Result: API call uses opus, TUI bottom bar stays on sonnet.
|
||||
*/
|
||||
export function applyUltraworkModelOverrideOnMessage(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
inputAgentName: string | undefined,
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
},
|
||||
tui: unknown,
|
||||
sessionID?: string,
|
||||
): void {
|
||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||
if (!override) return
|
||||
|
||||
if (override.variant) {
|
||||
output.message["variant"] = override.variant
|
||||
output.message["thinking"] = override.variant
|
||||
function applyResolvedUltraworkOverride(args: {
|
||||
override: UltraworkOverrideResult
|
||||
validatedVariant: string | undefined
|
||||
output: { message: Record<string, unknown> }
|
||||
inputAgentName: string | undefined
|
||||
tui: unknown
|
||||
}): void {
|
||||
const { override, validatedVariant, output, inputAgentName, tui } = args
|
||||
if (validatedVariant) {
|
||||
output.message["variant"] = validatedVariant
|
||||
output.message["thinking"] = validatedVariant
|
||||
}
|
||||
|
||||
if (!override.providerID || !override.modelID) {
|
||||
return
|
||||
}
|
||||
if (!override.providerID || !override.modelID) return
|
||||
|
||||
const targetModel = { providerID: override.providerID, modelID: override.modelID }
|
||||
if (isSameModel(output.message.model, targetModel)) {
|
||||
@@ -134,7 +120,6 @@ export function applyUltraworkModelOverrideOnMessage(
|
||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||
output.message.model = targetModel
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"
|
||||
@@ -143,11 +128,7 @@ export function applyUltraworkModelOverrideOnMessage(
|
||||
(typeof output.message["agent"] === "string" ? (output.message["agent"] as string) : "unknown"),
|
||||
)
|
||||
|
||||
scheduleDeferredModelOverride(
|
||||
messageId,
|
||||
targetModel,
|
||||
override.variant,
|
||||
)
|
||||
scheduleDeferredModelOverride(messageId, targetModel, validatedVariant)
|
||||
|
||||
log(`[ultrawork-model-override] ${fromModel} -> ${override.modelID} (deferred DB)`, {
|
||||
agent: agentConfigKey,
|
||||
@@ -156,6 +137,53 @@ export function applyUltraworkModelOverrideOnMessage(
|
||||
showToast(
|
||||
tui,
|
||||
"Ultrawork Model Override",
|
||||
`${fromModel} \u2192 ${override.modelID}. Maximum precision engaged.`,
|
||||
`${fromModel} → ${override.modelID}. Maximum precision engaged.`,
|
||||
)
|
||||
}
|
||||
|
||||
export function applyUltraworkModelOverrideOnMessage(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
inputAgentName: string | undefined,
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
},
|
||||
tui: unknown,
|
||||
sessionID?: string,
|
||||
client?: unknown,
|
||||
): void | Promise<void> {
|
||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||
if (!override) return
|
||||
|
||||
const currentModel = getMessageModel(output.message.model)
|
||||
const variantTargetModel = override.providerID && override.modelID
|
||||
? { providerID: override.providerID, modelID: override.modelID }
|
||||
: currentModel
|
||||
|
||||
if (!client || typeof (client as { provider?: { list?: unknown } }).provider?.list !== "function") {
|
||||
applyResolvedUltraworkOverride({ override, validatedVariant: override.variant, output, inputAgentName, tui })
|
||||
return
|
||||
}
|
||||
|
||||
return resolveValidUltraworkVariant(client, variantTargetModel, override.variant)
|
||||
.then((validatedVariant) => {
|
||||
if (override.variant && !validatedVariant) {
|
||||
log("[ultrawork-model-override] Skip invalid ultrawork variant override", {
|
||||
variant: override.variant,
|
||||
providerID: variantTargetModel?.providerID,
|
||||
modelID: variantTargetModel?.modelID,
|
||||
})
|
||||
}
|
||||
|
||||
applyResolvedUltraworkOverride({ override, validatedVariant, output, inputAgentName, tui })
|
||||
})
|
||||
.catch((error) => {
|
||||
log("[ultrawork-model-override] Failed to validate ultrawork variant via SDK", {
|
||||
variant: override.variant,
|
||||
error: String(error),
|
||||
providerID: variantTargetModel?.providerID,
|
||||
modelID: variantTargetModel?.modelID,
|
||||
})
|
||||
applyResolvedUltraworkOverride({ override, validatedVariant: undefined, output, inputAgentName, tui })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import * as dbOverrideModule from "./ultrawork-db-model-override"
|
||||
import { applyUltraworkModelOverrideOnMessage } from "./ultrawork-model-override"
|
||||
import { resolveValidUltraworkVariant } from "./ultrawork-variant-availability"
|
||||
|
||||
describe("resolveValidUltraworkVariant", () => {
|
||||
function createClient(models: Record<string, Record<string, unknown>>) {
|
||||
return {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
all: Object.entries(models).map(([providerID, providerModels]) => ({
|
||||
id: providerID,
|
||||
models: providerModels,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("#given provider sdk metadata #when variant exists #then returns variant", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-opus-4-6": {
|
||||
variants: {
|
||||
max: {},
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveValidUltraworkVariant(
|
||||
client,
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
"max",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toBe("max")
|
||||
})
|
||||
|
||||
test("#given provider sdk metadata #when variant does not exist #then returns undefined", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-opus-4-6": {
|
||||
variants: {
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveValidUltraworkVariant(
|
||||
client,
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
"max",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyUltraworkModelOverrideOnMessage variant guard", () => {
|
||||
function createClient(models: Record<string, Record<string, unknown>>) {
|
||||
return {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
all: Object.entries(models).map(([providerID, providerModels]) => ({
|
||||
id: providerID,
|
||||
models: providerModels,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("#given ultrawork variant missing from target model #when override applies #then skips forced variant change", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-opus-4-6": {
|
||||
variants: {
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const dbOverrideSpy = spyOn(dbOverrideModule, "scheduleDeferredModelOverride").mockImplementation(() => {})
|
||||
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof applyUltraworkModelOverrideOnMessage>[0]
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
id: "msg_123",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await applyUltraworkModelOverrideOnMessage(
|
||||
config,
|
||||
"sisyphus",
|
||||
output,
|
||||
{ showToast: async () => {} },
|
||||
undefined,
|
||||
client,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
expect(output.message["thinking"]).toBeUndefined()
|
||||
expect(dbOverrideSpy).toHaveBeenCalledWith(
|
||||
"msg_123",
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
undefined,
|
||||
)
|
||||
dbOverrideSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("#given variant only ultrawork config without valid current model variant #when override applies #then skips override entirely", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: {
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const dbOverrideSpy = spyOn(dbOverrideModule, "scheduleDeferredModelOverride").mockImplementation(() => {})
|
||||
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof applyUltraworkModelOverrideOnMessage>[0]
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await applyUltraworkModelOverrideOnMessage(
|
||||
config,
|
||||
"sisyphus",
|
||||
output,
|
||||
{ showToast: async () => {} },
|
||||
undefined,
|
||||
client,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
expect(output.message["thinking"]).toBeUndefined()
|
||||
expect(dbOverrideSpy).not.toHaveBeenCalled()
|
||||
expect(output.message.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
dbOverrideSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { normalizeSDKResponse } from "../shared"
|
||||
|
||||
type ModelDescriptor = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type ProviderListClient = {
|
||||
provider?: {
|
||||
list?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderModelMetadata = {
|
||||
variants?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ProviderListEntry = {
|
||||
id?: string
|
||||
models?: Record<string, ProviderModelMetadata>
|
||||
}
|
||||
|
||||
type ProviderListData = {
|
||||
all?: ProviderListEntry[]
|
||||
}
|
||||
|
||||
export async function resolveValidUltraworkVariant(
|
||||
client: unknown,
|
||||
model: ModelDescriptor | undefined,
|
||||
variant: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!model || !variant) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerList = (client as ProviderListClient | null | undefined)?.provider?.list
|
||||
if (typeof providerList !== "function") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const response = await providerList()
|
||||
const data = normalizeSDKResponse<ProviderListData>(response, {})
|
||||
const providerEntry = data.all?.find((entry) => entry.id === model.providerID)
|
||||
const variants = providerEntry?.models?.[model.modelID]?.variants
|
||||
|
||||
if (!variants) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.hasOwn(variants, variant) ? variant : undefined
|
||||
}
|
||||
Reference in New Issue
Block a user