Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import { createMultimodalLookerAgent } from "./multimodal-looker"
|
||||||
|
|
||||||
|
describe("createMultimodalLookerAgent", () => {
|
||||||
|
test("prompt explicitly enumerates the agent's available tools to prevent death loop on small VL models", () => {
|
||||||
|
// given
|
||||||
|
const agent = createMultimodalLookerAgent("openai/gpt-5-nano")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const prompt = typeof agent.prompt === "string" ? agent.prompt : ""
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(prompt).toMatch(/available tools/i)
|
||||||
|
expect(prompt).toContain("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prompt instructs the agent never to call other tools", () => {
|
||||||
|
// given
|
||||||
|
const agent = createMultimodalLookerAgent("openai/gpt-5-nano")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const prompt = typeof agent.prompt === "string" ? agent.prompt : ""
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(prompt.toLowerCase()).toContain("never")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,8 @@ export function createMultimodalLookerAgent(model: string): AgentConfig {
|
|||||||
...restrictions,
|
...restrictions,
|
||||||
prompt: `You interpret media files that cannot be read as plain text.
|
prompt: `You interpret media files that cannot be read as plain text.
|
||||||
|
|
||||||
|
Your only available tools are 'read' and 'call_omo_agent'. Always use 'read' to load the file first, then analyze the returned content. Never attempt to call any other tool.
|
||||||
|
|
||||||
Your job: examine the attached file and extract ONLY what was requested.
|
Your job: examine the attached file and extract ONLY what was requested.
|
||||||
|
|
||||||
When to use you:
|
When to use you:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
|
|||||||
|
|
||||||
function mapClaudeModelString(model: string | undefined): string | undefined {
|
function mapClaudeModelString(model: string | undefined): string | undefined {
|
||||||
if (!model) return undefined
|
if (!model) return undefined
|
||||||
|
if (typeof model !== "string") return undefined
|
||||||
|
|
||||||
const trimmed = model.trim()
|
const trimmed = model.trim()
|
||||||
if (trimmed.length === 0) return undefined
|
if (trimmed.length === 0) return undefined
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createCommandExecuteBeforeHandler } from "./plugin/command-execute-befo
|
|||||||
import { createMessagesTransformHandler } from "./plugin/messages-transform"
|
import { createMessagesTransformHandler } from "./plugin/messages-transform"
|
||||||
import { createSystemTransformHandler } from "./plugin/system-transform"
|
import { createSystemTransformHandler } from "./plugin/system-transform"
|
||||||
import { createEventHandler } from "./plugin/event"
|
import { createEventHandler } from "./plugin/event"
|
||||||
|
import { createToolDefinitionHandler } from "./plugin/tool-definition"
|
||||||
import { createToolExecuteAfterHandler } from "./plugin/tool-execute-after"
|
import { createToolExecuteAfterHandler } from "./plugin/tool-execute-after"
|
||||||
import { createToolExecuteBeforeHandler } from "./plugin/tool-execute-before"
|
import { createToolExecuteBeforeHandler } from "./plugin/tool-execute-before"
|
||||||
|
|
||||||
@@ -70,6 +71,10 @@ export function createPluginInterface(args: {
|
|||||||
hooks,
|
hooks,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
"tool.definition": createToolDefinitionHandler({
|
||||||
|
hooks,
|
||||||
|
}),
|
||||||
|
|
||||||
"tool.execute.before": createToolExecuteBeforeHandler({
|
"tool.execute.before": createToolExecuteBeforeHandler({
|
||||||
ctx,
|
ctx,
|
||||||
hooks,
|
hooks,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import { createToolDefinitionHandler } from "./tool-definition"
|
||||||
|
import { createTodoDescriptionOverrideHook } from "../hooks/todo-description-override/hook"
|
||||||
|
import { TODOWRITE_DESCRIPTION } from "../hooks/todo-description-override/description"
|
||||||
|
import type { CreatedHooks } from "../create-hooks"
|
||||||
|
|
||||||
|
function buildHooks(overrides: Partial<CreatedHooks> = {}): CreatedHooks {
|
||||||
|
return overrides as CreatedHooks
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createToolDefinitionHandler (regression for #3705)", () => {
|
||||||
|
describe("#given todoDescriptionOverride hook is registered", () => {
|
||||||
|
describe("#when the tool.definition handler runs for the todowrite tool", () => {
|
||||||
|
it("#then forwards to the hook and rewrites the description", async () => {
|
||||||
|
//#given
|
||||||
|
const handler = createToolDefinitionHandler({
|
||||||
|
hooks: buildHooks({ todoDescriptionOverride: createTodoDescriptionOverrideHook() }),
|
||||||
|
})
|
||||||
|
const output = { description: "opencode core default", parameters: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler({ toolID: "todowrite" }, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.description).toBe(TODOWRITE_DESCRIPTION)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when the tool.definition handler runs for any other tool", () => {
|
||||||
|
it("#then leaves the description untouched", async () => {
|
||||||
|
//#given
|
||||||
|
const handler = createToolDefinitionHandler({
|
||||||
|
hooks: buildHooks({ todoDescriptionOverride: createTodoDescriptionOverrideHook() }),
|
||||||
|
})
|
||||||
|
const output = { description: "bash native description", parameters: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler({ toolID: "bash" }, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.description).toBe("bash native description")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given todoDescriptionOverride hook is disabled (null)", () => {
|
||||||
|
describe("#when the tool.definition handler runs for todowrite", () => {
|
||||||
|
it("#then is a no-op", async () => {
|
||||||
|
//#given
|
||||||
|
const handler = createToolDefinitionHandler({
|
||||||
|
hooks: buildHooks({ todoDescriptionOverride: null }),
|
||||||
|
})
|
||||||
|
const output = { description: "opencode default kept", parameters: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler({ toolID: "todowrite" }, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.description).toBe("opencode default kept")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { CreatedHooks } from "../create-hooks"
|
||||||
|
|
||||||
|
export function createToolDefinitionHandler(args: {
|
||||||
|
hooks: CreatedHooks
|
||||||
|
}): (
|
||||||
|
input: { toolID: string },
|
||||||
|
output: { description: string; parameters: unknown },
|
||||||
|
) => Promise<void> {
|
||||||
|
const { hooks } = args
|
||||||
|
return async (input, output) => {
|
||||||
|
const overrideHook = hooks.todoDescriptionOverride
|
||||||
|
if (overrideHook) {
|
||||||
|
await overrideHook["tool.definition"](input, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -395,3 +395,65 @@ describe("findMostSpecificFallbackEntry", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: type-guard against non-string model field (issue #4145).
|
||||||
|
// Crash signature was `model.trim is not a function` aborting session.processor
|
||||||
|
// for every provider when a caller forwarded a FallbackModelObject where a
|
||||||
|
// plain string was expected.
|
||||||
|
describe("parseFallbackModelEntry: non-string input (issue #4145)", () => {
|
||||||
|
test("returns undefined when caller forwards an object instead of a string", () => {
|
||||||
|
//#given
|
||||||
|
const wrong = { model: "anthropic/claude-opus-4-7", variant: "high" } as unknown as string
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const parsed = parseFallbackModelEntry(wrong, "anthropic")
|
||||||
|
|
||||||
|
//#then: must not throw, must reject the malformed entry
|
||||||
|
expect(parsed).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined for null and undefined", () => {
|
||||||
|
//#given
|
||||||
|
const nullInput = null as unknown as string
|
||||||
|
const undefinedInput = undefined as unknown as string
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(parseFallbackModelEntry(nullInput, "anthropic")).toBeUndefined()
|
||||||
|
expect(parseFallbackModelEntry(undefinedInput, "anthropic")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseFallbackModelObjectEntry returns undefined when nested model field is non-string", () => {
|
||||||
|
//#given: FallbackModelObject whose .model was somehow forwarded as a nested
|
||||||
|
//object instead of a flat string (issue #4145 reproduction).
|
||||||
|
const malformedObject = {
|
||||||
|
model: ({ model: "claude-opus-4-7" } as unknown) as string,
|
||||||
|
variant: "high",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const parsed = parseFallbackModelObjectEntry(malformedObject, "anthropic")
|
||||||
|
|
||||||
|
//#then: must not throw, must reject the malformed entry
|
||||||
|
expect(parsed).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("buildFallbackChainFromModels skips entries whose model is a number", () => {
|
||||||
|
//#given
|
||||||
|
const fallbackModels = [
|
||||||
|
"openai/gpt-5.5",
|
||||||
|
(42 as unknown) as string,
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const chain = buildFallbackChainFromModels(fallbackModels, "openai")
|
||||||
|
|
||||||
|
//#then: only the valid string survives, the number is dropped without crashing
|
||||||
|
expect(chain).toEqual([
|
||||||
|
{
|
||||||
|
providers: ["openai"],
|
||||||
|
model: "gpt-5.5",
|
||||||
|
variant: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { normalizeFallbackModels } from "./model-resolver"
|
|||||||
import { KNOWN_VARIANTS } from "./known-variants"
|
import { KNOWN_VARIANTS } from "./known-variants"
|
||||||
|
|
||||||
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||||
|
if (typeof rawModel !== "string") {
|
||||||
|
return { modelID: "" }
|
||||||
|
}
|
||||||
const trimmedModel = rawModel.trim()
|
const trimmedModel = rawModel.trim()
|
||||||
if (!trimmedModel) {
|
if (!trimmedModel) {
|
||||||
return { modelID: "" }
|
return { modelID: "" }
|
||||||
@@ -33,6 +36,7 @@ export function parseFallbackModelEntry(
|
|||||||
contextProviderID: string | undefined,
|
contextProviderID: string | undefined,
|
||||||
defaultProviderID = "opencode",
|
defaultProviderID = "opencode",
|
||||||
): FallbackEntry | undefined {
|
): FallbackEntry | undefined {
|
||||||
|
if (typeof model !== "string") return undefined
|
||||||
const trimmed = model.trim()
|
const trimmed = model.trim()
|
||||||
if (!trimmed) return undefined
|
if (!trimmed) return undefined
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([
|
|||||||
])
|
])
|
||||||
|
|
||||||
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||||
|
if (typeof rawModelID !== "string") {
|
||||||
|
return { modelID: "" }
|
||||||
|
}
|
||||||
const trimmedModelID = rawModelID.trim()
|
const trimmedModelID = rawModelID.trim()
|
||||||
if (!trimmedModelID) {
|
if (!trimmedModelID) {
|
||||||
return { modelID: "" }
|
return { modelID: "" }
|
||||||
@@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string;
|
|||||||
export function parseModelString(
|
export function parseModelString(
|
||||||
model: string,
|
model: string,
|
||||||
): { providerID: string; modelID: string; variant?: string } | undefined {
|
): { providerID: string; modelID: string; variant?: string } | undefined {
|
||||||
|
if (typeof model !== "string") return undefined
|
||||||
const trimmedModel = model.trim()
|
const trimmedModel = model.trim()
|
||||||
if (!trimmedModel) return undefined
|
if (!trimmedModel) return undefined
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([
|
|||||||
])
|
])
|
||||||
|
|
||||||
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||||
|
if (typeof rawModelID !== "string") {
|
||||||
|
return { modelID: "" }
|
||||||
|
}
|
||||||
const trimmedModelID = rawModelID.trim()
|
const trimmedModelID = rawModelID.trim()
|
||||||
if (!trimmedModelID) {
|
if (!trimmedModelID) {
|
||||||
return { modelID: "" }
|
return { modelID: "" }
|
||||||
@@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string;
|
|||||||
export function parseModelString(
|
export function parseModelString(
|
||||||
model: string,
|
model: string,
|
||||||
): { providerID: string; modelID: string; variant?: string } | undefined {
|
): { providerID: string; modelID: string; variant?: string } | undefined {
|
||||||
|
if (typeof model !== "string") return undefined
|
||||||
const trimmedModel = model.trim()
|
const trimmedModel = model.trim()
|
||||||
if (!trimmedModel) return undefined
|
if (!trimmedModel) return undefined
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user