Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5530bbe0ac |
@@ -348,8 +348,8 @@ export const dict = {
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
|
||||
"zen.api.error.modelFormatNotSupported": "Model {{model}} is not supported for format {{format}}",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||
"zen.api.error.modelFormatNotSupported": "Model {{model}} not supported for format {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "No provider available",
|
||||
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
|
||||
"zen.api.error.missingApiKey": "Missing API key.",
|
||||
|
||||
@@ -19,15 +19,7 @@ export type Ruleset = typeof Ruleset.Type
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
return (
|
||||
rulesets
|
||||
.flat()
|
||||
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
|
||||
action: "ask",
|
||||
permission,
|
||||
pattern: "*",
|
||||
}
|
||||
)
|
||||
return select(permission, pattern, rulesets.flat())?.rule ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
@@ -38,8 +30,52 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
return new Set(
|
||||
tools.filter((tool) => {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
return rule?.pattern === "*" && rule.action === "deny"
|
||||
if (
|
||||
ruleset.some(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && rule.pattern !== "*" && rule.action !== "deny",
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return evaluate(permission, "*", ruleset).action === "deny"
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function select(permission: string, pattern: string, ruleset: Ruleset) {
|
||||
return ruleset.reduce<Selected | undefined>((best, rule, index) => {
|
||||
if (!Wildcard.match(permission, rule.permission) || !Wildcard.match(pattern, rule.pattern)) return best
|
||||
const next = { rule, index, permission: specificity(rule.permission), pattern: specificity(rule.pattern) }
|
||||
if (!best) return next
|
||||
return compare(next, best) >= 0 ? next : best
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
type Selected = {
|
||||
rule: Rule
|
||||
index: number
|
||||
permission: Specificity
|
||||
pattern: Specificity
|
||||
}
|
||||
|
||||
type Specificity = {
|
||||
wildcard: number
|
||||
literal: number
|
||||
}
|
||||
|
||||
function specificity(pattern: string): Specificity {
|
||||
return {
|
||||
wildcard: [...pattern.matchAll(/[?*]/g)].length,
|
||||
literal: pattern.replace(/[?*]/g, "").length,
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a: Selected, b: Selected) {
|
||||
return (
|
||||
b.permission.wildcard - a.permission.wildcard ||
|
||||
a.permission.literal - b.permission.literal ||
|
||||
b.pattern.wildcard - a.pattern.wildcard ||
|
||||
a.pattern.literal - b.pattern.literal ||
|
||||
a.index - b.index
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
||||
const log = Log.create({ service: "session.tools" })
|
||||
const schemaInternalKeys = new Set(["_def", "def", "_zod", "~standard", "_cached", "typeName"])
|
||||
|
||||
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
agent: Agent.Info
|
||||
@@ -79,7 +78,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
assertNoSchemaInternals(item.id, schema)
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
@@ -123,7 +121,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
|
||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||
const transformed = ProviderTransform.schema(input.model, schema)
|
||||
assertNoSchemaInternals(key, transformed)
|
||||
item.inputSchema = jsonSchema(transformed)
|
||||
item.execute = (args, opts) =>
|
||||
run.promise(
|
||||
@@ -208,31 +205,4 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
return tools
|
||||
})
|
||||
|
||||
function assertNoSchemaInternals(toolID: string, schema: unknown) {
|
||||
const path = schemaInternalPath(schema)
|
||||
if (!path) return
|
||||
throw new Error(`Tool ${toolID} input schema contains non-JSON-Schema Zod internals at ${path}`)
|
||||
}
|
||||
|
||||
function schemaInternalPath(value: unknown, path = "$", skipKeys = false): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item, index) => schemaInternalPath(item, `${path}[${index}]`))
|
||||
.find((item): item is string => item !== undefined)
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return undefined
|
||||
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const nextPath = /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`
|
||||
if (!skipKeys && schemaInternalKeys.has(key)) return nextPath
|
||||
const found = schemaInternalPath(
|
||||
item,
|
||||
nextPath,
|
||||
key === "properties" || key === "$defs" || key === "definitions" || key === "patternProperties",
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export * as SessionTools from "./tools"
|
||||
|
||||
@@ -55,13 +55,6 @@ import { Reference } from "@/reference/reference"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import {
|
||||
objectFromShape,
|
||||
safeParse,
|
||||
type AnyObjectSchema,
|
||||
type AnySchema,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js"
|
||||
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
@@ -157,10 +150,10 @@ export const layer: Layer.Layer<
|
||||
const args = def.args ?? {}
|
||||
const entries = Object.entries(args)
|
||||
const allZod = entries.every((entry) => isZodType(entry[1]))
|
||||
const zodParams = allZod ? objectFromShape(args as Record<string, AnySchema>) : undefined
|
||||
const zodParams = allZod ? z.object(args) : undefined
|
||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||
const parameters = zodParams
|
||||
? Schema.declare<unknown>((u): u is unknown => safeParse(zodParams, u).success)
|
||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||
: Schema.Unknown
|
||||
return {
|
||||
id,
|
||||
@@ -409,11 +402,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
.pipe(Layer.provide(RuntimeFlags.defaultLayer)),
|
||||
)
|
||||
|
||||
function isZodType(value: unknown): value is AnySchema {
|
||||
return typeof value === "object" && value !== null && ("_zod" in value || "_def" in value)
|
||||
}
|
||||
|
||||
function isZod4Type(value: unknown): value is z.ZodType {
|
||||
function isZodType(value: unknown): value is z.ZodType {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
@@ -436,12 +425,8 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
|
||||
}
|
||||
}
|
||||
|
||||
function zodJsonSchema(schema: AnyObjectSchema): JSONSchema7 {
|
||||
const result = normalizeZodJsonSchema(
|
||||
isZod4Type(schema)
|
||||
? z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })
|
||||
: toJsonSchemaCompat(schema, { pipeStrategy: "input" }),
|
||||
)
|
||||
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
|
||||
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) }))
|
||||
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
|
||||
const { $defs, ...rest } = result
|
||||
return (
|
||||
@@ -449,7 +434,7 @@ function zodJsonSchema(schema: AnyObjectSchema): JSONSchema7 {
|
||||
) as JSONSchema7
|
||||
}
|
||||
|
||||
function zodMetadataRegistry(schema: AnyObjectSchema) {
|
||||
function zodMetadataRegistry(schema: z.ZodType) {
|
||||
const registry = z.registry<Record<string, unknown>>()
|
||||
const seen = new WeakSet<object>()
|
||||
const collect = (value: unknown) => {
|
||||
@@ -457,7 +442,7 @@ function zodMetadataRegistry(schema: AnyObjectSchema) {
|
||||
if (seen.has(value)) return
|
||||
seen.add(value)
|
||||
|
||||
if (isZod4Type(value)) {
|
||||
if (isZodType(value)) {
|
||||
const metadata = typeof value.meta === "function" ? value.meta() : undefined
|
||||
const description = typeof value.description === "string" ? value.description : undefined
|
||||
const merged = {
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Permission.evaluate for permission.task", () => {
|
||||
expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("later rules take precedence (last match wins)", () => {
|
||||
test("more specific rules take precedence", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
@@ -73,8 +73,9 @@ describe("Permission.evaluate for permission.task", () => {
|
||||
|
||||
describe("Permission.disabled for task tool", () => {
|
||||
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
|
||||
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
|
||||
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
|
||||
// It only disables a tool when every possible call is denied. Specific allow
|
||||
// or ask patterns keep the tool available; runtime evaluation handles the
|
||||
// individual subagent patterns.
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
@@ -82,26 +83,22 @@ describe("Permission.disabled for task tool", () => {
|
||||
action,
|
||||
}))
|
||||
|
||||
test("task tool is disabled when global deny pattern exists (even with specific allows)", () => {
|
||||
// When "*": "deny" exists, the task tool is disabled because the disabled() function
|
||||
// only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed
|
||||
test("task tool is not disabled when global deny has specific allows", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "allow",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => {
|
||||
test("task tool is not disabled when global deny has specific asks", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "ask",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is disabled when global deny pattern exists", () => {
|
||||
@@ -111,14 +108,12 @@ describe("Permission.disabled for task tool", () => {
|
||||
})
|
||||
|
||||
test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => {
|
||||
// The disabled() function only disables tools when pattern: "*" && action: "deny"
|
||||
// Specific subagent denies don't disable the task tool - those are handled at runtime
|
||||
// Specific subagent denies don't disable the task tool - those are handled at runtime.
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "deny",
|
||||
general: "deny",
|
||||
})
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -127,16 +122,12 @@ describe("Permission.disabled for task tool", () => {
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is NOT disabled when last wildcard pattern is allow", () => {
|
||||
// Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled
|
||||
test("task tool is NOT disabled when wildcard deny has a specific allow", () => {
|
||||
const ruleset = createRuleset({
|
||||
"*": "deny",
|
||||
"orchestrator-coder": "allow",
|
||||
})
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The disabled() function uses findLast and checks if the last matching rule
|
||||
// has pattern: "*" and action: "deny". In this case, the last rule matching
|
||||
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -234,8 +225,7 @@ describe("permission.task with real config files", () => {
|
||||
const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
|
||||
expect(disabled.has("bash")).toBe(false)
|
||||
expect(disabled.has("edit")).toBe(false)
|
||||
// task is NOT disabled because disabled() uses findLast, and the last rule
|
||||
// matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
|
||||
// task is NOT disabled because the specific allow leaves at least one subagent available.
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
}),
|
||||
{
|
||||
@@ -254,21 +244,18 @@ describe("permission.task with real config files", () => {
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"task tool disabled when global deny comes last in config",
|
||||
"specific task allows beat global deny regardless of order",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* load
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
|
||||
|
||||
// Since "*": "deny" is the last rule, disabled() finds it with findLast
|
||||
// and sees pattern: "*" with action: "deny", so task is disabled
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
@@ -285,20 +272,17 @@ describe("permission.task with real config files", () => {
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"task tool NOT disabled when specific allow comes last in config",
|
||||
"task tool NOT disabled when global deny has a specific allow",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* load
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
// Other agents still denied by the earlier "*" deny
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
|
||||
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
|
||||
// So the task tool is NOT disabled (even though most subagents are denied)
|
||||
// The task tool remains available because the specific allow leaves one subagent callable.
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
}),
|
||||
|
||||
@@ -129,9 +129,8 @@ test("fromConfig - does not expand tilde in middle of path", () => {
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
|
||||
})
|
||||
|
||||
// Permission precedence follows config insertion order. `evaluate()` uses the
|
||||
// last matching rule, so later config entries intentionally override earlier
|
||||
// entries even when a wildcard appears after a specific permission.
|
||||
// Permission precedence follows specificity. Later config entries only override
|
||||
// earlier entries when the matching rules are equally specific.
|
||||
|
||||
test("fromConfig - preserves top-level config key order", () => {
|
||||
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
|
||||
@@ -141,7 +140,7 @@ test("fromConfig - preserves top-level config key order", () => {
|
||||
expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"])
|
||||
|
||||
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("fromConfig - wildcard acts as fallback when it appears before specifics", () => {
|
||||
@@ -288,7 +287,7 @@ test("evaluate - wildcard pattern match", () => {
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins", () => {
|
||||
test("evaluate - more specific pattern wins over wildcard", () => {
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
@@ -296,12 +295,12 @@ test("evaluate - last matching rule wins", () => {
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
test("evaluate - specific pattern wins when wildcard appears later", () => {
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result.action).toBe("allow")
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - glob pattern match", () => {
|
||||
@@ -309,7 +308,7 @@ test("evaluate - glob pattern match", () => {
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching glob wins", () => {
|
||||
test("evaluate - more specific glob wins", () => {
|
||||
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
@@ -317,12 +316,12 @@ test("evaluate - last matching glob wins", () => {
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - order matters for specificity", () => {
|
||||
test("evaluate - specific glob wins when broader glob appears later", () => {
|
||||
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
])
|
||||
expect(result.action).toBe("deny")
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - unknown permission returns ask", () => {
|
||||
@@ -373,12 +372,12 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
test("evaluate - earlier exact match wins over wildcard at end", () => {
|
||||
const result = Permission.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result.action).toBe("allow")
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
// wildcard permission tests
|
||||
@@ -433,12 +432,32 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - later wildcard permission can override earlier specific permission", () => {
|
||||
test("evaluate - specific permission wins over later wildcard permission", () => {
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
])
|
||||
expect(result.action).toBe("deny")
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - bash deny beats wildcard ask regardless of order", () => {
|
||||
const wildcardFirst = Permission.fromConfig({
|
||||
bash: {
|
||||
"*": "ask",
|
||||
"git checkout": "deny",
|
||||
"git checkout *": "deny",
|
||||
},
|
||||
})
|
||||
const wildcardLast = Permission.fromConfig({
|
||||
bash: {
|
||||
"git checkout": "deny",
|
||||
"git checkout *": "deny",
|
||||
"*": "ask",
|
||||
},
|
||||
})
|
||||
|
||||
expect(Permission.evaluate("bash", "git checkout -- backend/db/schema.py", wildcardFirst).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "git checkout -- backend/db/schema.py", wildcardLast).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - merges multiple rulesets", () => {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
|
||||
import { jsonSchema, tool, type ModelMessage } from "ai"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const baseModel: Provider.Model = {
|
||||
id: ModelID.make("gpt-5-mini"),
|
||||
@@ -70,10 +69,6 @@ const providerInfo: Provider.Info = {
|
||||
models: {},
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
||||
)
|
||||
|
||||
function responsesStream(chunks: unknown[]) {
|
||||
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", {
|
||||
status: 200,
|
||||
@@ -81,72 +76,6 @@ function responsesStream(chunks: unknown[]) {
|
||||
})
|
||||
}
|
||||
|
||||
type NativeRequestInput = Parameters<typeof LLMNative.request>[0]
|
||||
|
||||
const sessionText = (text: string) => ({ type: "text" as const, text })
|
||||
|
||||
const sessionOpenAIReasoning = (
|
||||
text: string,
|
||||
options: {
|
||||
readonly storedAs: "providerMetadata" | "providerOptions"
|
||||
readonly itemId: string
|
||||
readonly encryptedContent: string | null
|
||||
},
|
||||
) => {
|
||||
const metadata = {
|
||||
openai: { itemId: options.itemId, reasoningEncryptedContent: options.encryptedContent },
|
||||
}
|
||||
if (options.storedAs === "providerMetadata")
|
||||
return Object.assign({ type: "reasoning" as const, text }, { providerMetadata: metadata })
|
||||
return Object.assign({ type: "reasoning" as const, text }, { providerOptions: metadata })
|
||||
}
|
||||
|
||||
type SessionAssistantPart = ReturnType<typeof sessionText> | ReturnType<typeof sessionOpenAIReasoning>
|
||||
|
||||
const storedSession = {
|
||||
user: (content: string): ModelMessage => ({ role: "user", content }),
|
||||
assistant: (content: SessionAssistantPart[]): ModelMessage => ({ role: "assistant", content }),
|
||||
text: sessionText,
|
||||
openaiReasoning: sessionOpenAIReasoning,
|
||||
}
|
||||
|
||||
const openAIResponses = {
|
||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
|
||||
type: "reasoning",
|
||||
id: options.itemId,
|
||||
encrypted_content: options.encryptedContent,
|
||||
summary: [{ type: "summary_text", text }],
|
||||
}),
|
||||
}
|
||||
|
||||
const prepareNativeRequest = (input: NativeRequestInput) => LLMClient.prepare(LLMNative.request(input))
|
||||
|
||||
const expectOpenAIResponsesRequest = (input: {
|
||||
readonly history: NativeRequestInput["messages"]
|
||||
readonly providerOptions?: NativeRequestInput["providerOptions"]
|
||||
readonly maxOutputTokens?: NativeRequestInput["maxOutputTokens"]
|
||||
readonly headers?: NativeRequestInput["headers"]
|
||||
readonly expectedBody: unknown
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* prepareNativeRequest({
|
||||
model: baseModel,
|
||||
apiKey: "test-openai-key",
|
||||
messages: input.history,
|
||||
providerOptions: input.providerOptions,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
headers: input.headers,
|
||||
}),
|
||||
).toMatchObject({
|
||||
route: "openai-responses",
|
||||
protocol: "openai-responses",
|
||||
body: input.expectedBody,
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.llm-native.request", () => {
|
||||
test("maps normalized stream inputs to a native LLM request", () => {
|
||||
const messages: ModelMessage[] = [
|
||||
@@ -497,163 +426,122 @@ describe("session.llm-native.request", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("native tool wrapper converts thrown errors into typed ToolFailure", () =>
|
||||
Effect.gen(function* () {
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{
|
||||
explode: {
|
||||
description: "always throws",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
execute: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
} satisfies Tool,
|
||||
},
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{
|
||||
explode: {
|
||||
description: "always throws",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
execute: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
|
||||
const failure = yield* Effect.flip(wrapped.explode.execute({}, { id: "call-1", name: "explode" }))
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect(failure.message).toBe("boom")
|
||||
}),
|
||||
)
|
||||
const failure = await Effect.runPromise(
|
||||
Effect.flip(wrapped.explode!.execute!({}, { id: "call-1", name: "explode" })),
|
||||
)
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect((failure as ToolFailure).message).toBe("boom")
|
||||
})
|
||||
|
||||
it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () =>
|
||||
Effect.gen(function* () {
|
||||
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } satisfies Tool },
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
test("native tool wrapper raises ToolFailure when the source tool has no execute handler", async () => {
|
||||
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } as any },
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
|
||||
const failure = yield* Effect.flip(wrapped.incomplete.execute({}, { id: "call-1", name: "incomplete" }))
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect(failure.message).toContain("incomplete")
|
||||
}),
|
||||
)
|
||||
const failure = await Effect.runPromise(
|
||||
Effect.flip(wrapped.incomplete!.execute!({}, { id: "call-1", name: "incomplete" })),
|
||||
)
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect((failure as ToolFailure).message).toContain("incomplete")
|
||||
})
|
||||
|
||||
it.effect("compiles through the native OpenAI Responses route", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [storedSession.user("hello")],
|
||||
providerOptions: { openai: { store: false, instructions: "You are concise." } },
|
||||
maxOutputTokens: 512,
|
||||
headers: { "x-request": "request-header" },
|
||||
expectedBody: {
|
||||
test("compiles through the native OpenAI Responses route", async () => {
|
||||
const prepared = await Effect.runPromise(
|
||||
LLMClient.prepare(
|
||||
LLMNative.request({
|
||||
model: baseModel,
|
||||
apiKey: "test-openai-key",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
providerOptions: { openai: { store: false, instructions: "You are concise." } },
|
||||
maxOutputTokens: 512,
|
||||
headers: { "x-request": "request-header" },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(LLMClient.layer),
|
||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
route: "openai-responses",
|
||||
protocol: "openai-responses",
|
||||
body: {
|
||||
model: "gpt-5-mini",
|
||||
instructions: "You are concise.",
|
||||
input: [openAIResponses.user("hello")],
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
max_output_tokens: 512,
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("omits non-persisted OpenAI reasoning ids without encrypted state", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.user("What changed?"),
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||
storedAs: "providerOptions",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: null,
|
||||
}),
|
||||
storedSession.text("The parser changed."),
|
||||
]),
|
||||
storedSession.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
openAIResponses.assistant("The parser changed."),
|
||||
openAIResponses.user("Summarize it."),
|
||||
],
|
||||
store: false,
|
||||
test("uses provider fetch override for native OpenAI OAuth requests", async () => {
|
||||
const captures: Array<{ url: string; body: unknown }> = []
|
||||
const customFetch = (async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
captures.push({ url: request.url, body: await request.clone().json() })
|
||||
return responsesStream([
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
||||
])
|
||||
}) as typeof fetch
|
||||
|
||||
const events = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const llmClient = yield* LLMClient.Service
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: baseModel,
|
||||
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
|
||||
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
llmClient,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: {},
|
||||
providerOptions: { instructions: "You are concise." },
|
||||
headers: {},
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
expect(native.type).toBe("supported")
|
||||
if (native.type === "unsupported") return []
|
||||
return yield* native.stream.pipe(Stream.runCollect)
|
||||
}).pipe(
|
||||
Effect.provide(LLMClient.layer),
|
||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(captures).toHaveLength(1)
|
||||
expect(captures[0]).toMatchObject({
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
body: {
|
||||
model: "gpt-5-mini",
|
||||
instructions: "You are concise.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves encrypted OpenAI reasoning state through native request lowering", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.user("What changed?"),
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||
storedAs: "providerMetadata",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
storedSession.text("The parser changed."),
|
||||
]),
|
||||
storedSession.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
openAIResponses.openaiReasoning("Checked the previous diff.", {
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
openAIResponses.assistant("The parser changed."),
|
||||
openAIResponses.user("Summarize it."),
|
||||
],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const captures: Array<{ url: string; body: unknown }> = []
|
||||
const customFetch = Object.assign(
|
||||
async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
captures.push({ url: request.url, body: await request.clone().json() })
|
||||
return responsesStream([
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
||||
])
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
) satisfies typeof fetch
|
||||
|
||||
const llmClient = yield* LLMClient.Service
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: baseModel,
|
||||
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
|
||||
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
llmClient,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: {},
|
||||
providerOptions: { instructions: "You are concise." },
|
||||
headers: {},
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
expect(native.type).toBe("supported")
|
||||
if (native.type === "unsupported") throw new Error(native.reason)
|
||||
const events = Array.from(yield* native.stream.pipe(Stream.runCollect))
|
||||
|
||||
expect(captures).toHaveLength(1)
|
||||
expect(captures[0]).toMatchObject({
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
body: {
|
||||
model: "gpt-5-mini",
|
||||
instructions: "You are concise.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
},
|
||||
})
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text-delta", text: "Hello" }),
|
||||
expect.objectContaining({ type: "finish" }),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text-delta", text: "Hello" }),
|
||||
expect.objectContaining({ type: "finish" }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { jsonSchema } from "ai"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { SessionTools } from "@/session/tools"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
ToolRegistry.Service,
|
||||
ToolRegistry.Service.of({
|
||||
ids: () => Effect.succeed([]),
|
||||
all: () => Effect.succeed([]),
|
||||
named: () => Effect.die("unexpected named tool lookup"),
|
||||
tools: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
status: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed({}),
|
||||
tools: () =>
|
||||
Effect.succeed({
|
||||
ctx_batch_execute: {
|
||||
description: "context tool",
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
batch: {
|
||||
type: "array",
|
||||
items: schemaWithZodInternals(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
execute: () => Promise.resolve({ content: [{ type: "text" as const, text: "ok" }] }),
|
||||
},
|
||||
}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||
connect: () => Effect.void,
|
||||
disconnect: () => Effect.void,
|
||||
getPrompt: () => Effect.succeed(undefined),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
startAuth: () => Effect.die("unexpected MCP auth"),
|
||||
authenticate: () => Effect.die("unexpected MCP auth"),
|
||||
finishAuth: () => Effect.die("unexpected MCP auth"),
|
||||
removeAuth: () => Effect.void,
|
||||
supportsOAuth: () => Effect.succeed(false),
|
||||
hasStoredTokens: () => Effect.succeed(false),
|
||||
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Plugin.Service,
|
||||
Plugin.Service.of({
|
||||
trigger: (_name, _input, output) => Effect.succeed(output),
|
||||
list: () => Effect.succeed([]),
|
||||
init: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
ask: () => Effect.void,
|
||||
reply: () => Effect.void,
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Truncate.Service,
|
||||
Truncate.Service.of({
|
||||
cleanup: () => Effect.void,
|
||||
write: () => Effect.succeed("/tmp/tool-output"),
|
||||
output: (text) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
limits: () => Effect.succeed({ maxLines: 2000, maxBytes: 50 * 1024 }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("SessionTools.resolve", () => {
|
||||
it.effect("fails locally when MCP schemas contain Zod internals", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* SessionTools.resolve({
|
||||
agent: agentInfo(),
|
||||
model: kimiModel(),
|
||||
session: sessionInfo(),
|
||||
processor: processor(),
|
||||
bypassAgentCheck: false,
|
||||
messages: [],
|
||||
promptOps: promptOps(),
|
||||
}).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (!Exit.isFailure(exit)) return
|
||||
|
||||
expect(String(exit.cause)).toContain("ctx_batch_execute")
|
||||
expect(String(exit.cause)).toContain("non-JSON-Schema Zod internals")
|
||||
expect(String(exit.cause)).toContain("$.properties.batch.items._zod")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function agentInfo(): Agent.Info {
|
||||
return {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
permission: [],
|
||||
options: {},
|
||||
}
|
||||
}
|
||||
|
||||
function schemaWithZodInternals() {
|
||||
return JSON.parse(
|
||||
JSON.stringify({
|
||||
_zod: { def: { type: "object" } },
|
||||
def: { type: "object" },
|
||||
typeName: "ZodObject",
|
||||
"~standard": { vendor: "zod" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function kimiModel(): Provider.Model {
|
||||
return {
|
||||
id: ModelID.make("kimi-k2.6"),
|
||||
providerID: ProviderID.make("moonshotai"),
|
||||
name: "Kimi K2.6",
|
||||
limit: { context: 128_000, output: 32_000 },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
capabilities: {
|
||||
toolcall: true,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
api: { id: "kimi-k2.6", url: "https://api.moonshot.example/v1", npm: "@ai-sdk/openai-compatible" },
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
status: "active",
|
||||
}
|
||||
}
|
||||
|
||||
function sessionInfo() {
|
||||
return {
|
||||
id: SessionID.descending(),
|
||||
slug: "test",
|
||||
projectID: ProjectID.global,
|
||||
directory: "/tmp/test",
|
||||
title: "test",
|
||||
version: "test",
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
function processor() {
|
||||
return {
|
||||
message: {
|
||||
id: MessageID.ascending(),
|
||||
sessionID: SessionID.descending(),
|
||||
role: "assistant",
|
||||
parentID: MessageID.ascending(),
|
||||
modelID: ModelID.make("kimi-k2.6"),
|
||||
providerID: ProviderID.make("moonshotai"),
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "/tmp/test", root: "/tmp/test" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: Date.now() },
|
||||
} satisfies MessageV2.Assistant,
|
||||
updateToolCall: () => Effect.succeed(undefined),
|
||||
completeToolCall: () => Effect.void,
|
||||
}
|
||||
}
|
||||
|
||||
function promptOps() {
|
||||
return {
|
||||
cancel: () => Effect.void,
|
||||
resolvePromptParts: (template: string) => Effect.succeed([{ type: "text" as const, text: template }]),
|
||||
prompt: () => Effect.die("unexpected prompt call"),
|
||||
loop: () => Effect.die("unexpected loop call"),
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import z3 from "zod/v3"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const configLayer = TestConfig.layer({
|
||||
@@ -81,7 +80,7 @@ const brokenPluginLayer = Layer.succeed(
|
||||
init: () => Effect.void,
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
list: (() =>
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
tool: {
|
||||
@@ -92,41 +91,7 @@ const brokenPluginLayer = Layer.succeed(
|
||||
},
|
||||
},
|
||||
},
|
||||
])) as unknown as Plugin.Interface["list"],
|
||||
}),
|
||||
)
|
||||
|
||||
const zod3PluginLayer = Layer.succeed(
|
||||
Plugin.Service,
|
||||
Plugin.Service.of({
|
||||
init: () => Effect.void,
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
list: (() =>
|
||||
Effect.succeed([
|
||||
{
|
||||
tool: {
|
||||
ctx_batch_execute: {
|
||||
description: "context-mode batch executor",
|
||||
args: {
|
||||
batch: z3
|
||||
.preprocess(
|
||||
(value) => (typeof value === "string" ? JSON.parse(value) : value),
|
||||
z3
|
||||
.array(
|
||||
z3.object({
|
||||
command: z3.string().describe("Command to execute"),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
)
|
||||
.describe("Commands to execute as a batch"),
|
||||
},
|
||||
execute: async () => "ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
])) as unknown as Plugin.Interface["list"],
|
||||
]),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -140,7 +105,6 @@ const background = testEffect(
|
||||
const withBrokenPlugin = testEffect(
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||
)
|
||||
const withZod3Plugin = testEffect(Layer.mergeAll(registryLayer({ plugin: zod3PluginLayer }), node, Agent.defaultLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -385,52 +349,6 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
withZod3Plugin.instance("loads plugin tools with Zod 3 args as JSON Schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute")
|
||||
if (!loaded) throw new Error("ctx_batch_execute tool was not loaded")
|
||||
|
||||
expect(loaded.jsonSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
batch: {
|
||||
type: "array",
|
||||
description: "Commands to execute as a batch",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string", description: "Command to execute" },
|
||||
},
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ["batch"],
|
||||
})
|
||||
expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_def")
|
||||
expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_zod")
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ batch: [{ command: "pwd" }] }))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
withZod3Plugin.instance("validates plugin tools with Zod 3 preprocessors", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute")
|
||||
if (!loaded) throw new Error("ctx_batch_execute tool was not loaded")
|
||||
|
||||
expect(
|
||||
Result.isSuccess(
|
||||
Schema.decodeUnknownResult(loaded.parameters)({ batch: JSON.stringify([{ command: "pwd" }]) }),
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"preserves Zod arg descriptions from older config-scoped plugin packages",
|
||||
() =>
|
||||
|
||||
@@ -535,7 +535,7 @@ This can take a glob pattern.
|
||||
```
|
||||
|
||||
And you can also use the `*` wildcard to manage permissions for all commands.
|
||||
Since the last matching rule takes precedence, put the `*` wildcard first and specific rules after.
|
||||
The most specific matching rule takes precedence, so specific command rules beat the `*` wildcard regardless of order.
|
||||
|
||||
```json title="opencode.json" {8}
|
||||
{
|
||||
@@ -622,7 +622,7 @@ Control which subagents an agent can invoke via the Task tool with `permission.t
|
||||
When set to `deny`, the subagent is removed from the Task tool description entirely, so the model won't attempt to invoke it.
|
||||
|
||||
:::tip
|
||||
Rules are evaluated in order, and the **last matching rule wins**. In the example above, `orchestrator-planner` matches both `*` (deny) and `orchestrator-*` (allow), but since `orchestrator-*` comes after `*`, the result is `allow`.
|
||||
Rules are evaluated by specificity. In the example above, `orchestrator-planner` matches both `*` (deny) and `orchestrator-*` (allow), but `orchestrator-*` is more specific, so the result is `allow`.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -68,7 +68,7 @@ For most permissions, you can use an object to apply different actions based on
|
||||
}
|
||||
```
|
||||
|
||||
Rules are evaluated by pattern match, with the **last matching rule winning**. A common pattern is to put the catch-all `"*"` rule first, and more specific rules after it.
|
||||
Rules are evaluated by pattern match, with the **most specific matching rule winning**. If two matching rules are equally specific, the later rule wins.
|
||||
|
||||
### Wildcards
|
||||
|
||||
|
||||
Reference in New Issue
Block a user