Compare commits
1 Commits
fix/issue-
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5073cc6b15 |
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { createHashlineEditTool } from "../tools/hashline-edit"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
|
||||
const tempDirectories: string[] = []
|
||||
@@ -19,6 +20,13 @@ function getNestedRecord(record: Record<string, unknown>, key: string): Record<s
|
||||
return isRecord(value) ? value : undefined
|
||||
}
|
||||
|
||||
function getRecordAtPath(record: Record<string, unknown>, path: string[]): Record<string, unknown> | undefined {
|
||||
return path.reduce<Record<string, unknown> | undefined>(
|
||||
(currentRecord, key) => (currentRecord ? getNestedRecord(currentRecord, key) : undefined),
|
||||
record,
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -94,4 +102,29 @@ describe("normalizeToolArgSchemas", () => {
|
||||
expect(afterQuery?.title).toBe("Query")
|
||||
expect(afterQuery?.examples).toEqual(["issue 2314"])
|
||||
})
|
||||
|
||||
it("collapses hashline lines union into a Vertex-compatible array schema", async () => {
|
||||
// given
|
||||
const hostZod = await loadSeparateHostZodModule()
|
||||
const toolDefinition = createHashlineEditTool()
|
||||
|
||||
// when
|
||||
const beforeSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const beforeLines = getRecordAtPath(beforeSchema, ["properties", "edits", "items", "properties", "lines"])
|
||||
|
||||
normalizeToolArgSchemas(toolDefinition)
|
||||
|
||||
const afterSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const afterLines = getRecordAtPath(afterSchema, ["properties", "edits", "items", "properties", "lines"])
|
||||
const afterItems = afterLines ? getNestedRecord(afterLines, "items") : undefined
|
||||
|
||||
// then
|
||||
expect(beforeLines?.type).toBeUndefined()
|
||||
expect(Array.isArray(beforeLines?.anyOf)).toBe(true)
|
||||
expect(afterLines?.type).toBe("array")
|
||||
expect(afterLines?.nullable).toBe(true)
|
||||
expect(afterLines?.anyOf).toBeUndefined()
|
||||
expect(afterItems?.type).toBe("string")
|
||||
expect(afterLines?.description).toBe("Replacement or inserted lines. null/[] deletes with replace")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,11 +9,106 @@ type SchemaWithJsonSchemaOverride = ToolArgSchema & {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stripRootJsonSchemaFields(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _schema, ...rest } = jsonSchema
|
||||
return rest
|
||||
}
|
||||
|
||||
function isNullSchema(jsonSchema: Record<string, unknown>): boolean {
|
||||
return jsonSchema.type === "null"
|
||||
}
|
||||
|
||||
function isStringSchema(jsonSchema: Record<string, unknown>): boolean {
|
||||
return jsonSchema.type === "string"
|
||||
}
|
||||
|
||||
function isStringArraySchema(jsonSchema: Record<string, unknown>): boolean {
|
||||
if (jsonSchema.type !== "array") {
|
||||
return false
|
||||
}
|
||||
|
||||
const items = jsonSchema.items
|
||||
return isRecord(items) && items.type === "string"
|
||||
}
|
||||
|
||||
function collapseNullableUnion(
|
||||
jsonSchema: Record<string, unknown>,
|
||||
variants: Record<string, unknown>[],
|
||||
): Record<string, unknown> | null {
|
||||
const nonNullVariants = variants.filter((variant) => !isNullSchema(variant))
|
||||
|
||||
if (nonNullVariants.length !== 1 || variants.length !== nonNullVariants.length + 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { anyOf: _anyOf, ...schemaWithoutAnyOf } = jsonSchema
|
||||
return {
|
||||
...nonNullVariants[0],
|
||||
...schemaWithoutAnyOf,
|
||||
nullable: true,
|
||||
}
|
||||
}
|
||||
|
||||
function collapseStringOrStringArrayUnion(
|
||||
jsonSchema: Record<string, unknown>,
|
||||
variants: Record<string, unknown>[],
|
||||
): Record<string, unknown> | null {
|
||||
const nonNullVariants = variants.filter((variant) => !isNullSchema(variant))
|
||||
const stringVariant = nonNullVariants.find(isStringSchema)
|
||||
const stringArrayVariant = nonNullVariants.find(isStringArraySchema)
|
||||
|
||||
if (!stringVariant || !stringArrayVariant || nonNullVariants.length !== 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { anyOf: _anyOf, ...schemaWithoutAnyOf } = jsonSchema
|
||||
|
||||
return {
|
||||
...stringArrayVariant,
|
||||
...schemaWithoutAnyOf,
|
||||
nullable: variants.length !== nonNullVariants.length,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAnyOfUnion(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const anyOf = jsonSchema.anyOf
|
||||
if (!Array.isArray(anyOf) || jsonSchema.type !== undefined) {
|
||||
return jsonSchema
|
||||
}
|
||||
|
||||
const variants = anyOf.filter(isRecord)
|
||||
if (variants.length !== anyOf.length) {
|
||||
return jsonSchema
|
||||
}
|
||||
|
||||
return collapseNullableUnion(jsonSchema, variants) ?? collapseStringOrStringArrayUnion(jsonSchema, variants) ?? jsonSchema
|
||||
}
|
||||
|
||||
function normalizeJsonSchemaValue(jsonSchema: unknown): unknown {
|
||||
if (Array.isArray(jsonSchema)) {
|
||||
return jsonSchema.map((item) => normalizeJsonSchemaValue(item))
|
||||
}
|
||||
|
||||
if (!isRecord(jsonSchema)) {
|
||||
return jsonSchema
|
||||
}
|
||||
|
||||
return normalizeJsonSchema(jsonSchema)
|
||||
}
|
||||
|
||||
function normalizeJsonSchema(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(jsonSchema)) {
|
||||
normalized[key] = normalizeJsonSchemaValue(value)
|
||||
}
|
||||
|
||||
return normalizeAnyOfUnion(normalized)
|
||||
}
|
||||
|
||||
function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
|
||||
if (schema._zod.toJSONSchema) {
|
||||
return
|
||||
@@ -24,7 +119,7 @@ function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
|
||||
delete schema._zod.toJSONSchema
|
||||
|
||||
try {
|
||||
return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema))
|
||||
return normalizeJsonSchema(stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema)))
|
||||
} finally {
|
||||
schema._zod.toJSONSchema = originalOverride
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ type DelegateTaskArgsWithSerializedSkills = Omit<DelegateTaskArgs, "load_skills"
|
||||
load_skills: string
|
||||
}
|
||||
|
||||
type DelegateTaskArgsWithOptionalBackground = Omit<DelegateTaskArgs, "run_in_background"> & {
|
||||
run_in_background?: boolean
|
||||
}
|
||||
|
||||
function createTestAvailableModels(): Set<string> {
|
||||
return new Set(TEST_AVAILABLE_MODELS)
|
||||
}
|
||||
@@ -405,61 +401,6 @@ describe("sisyphus-task", () => {
|
||||
}, { timeout: 10000 })
|
||||
})
|
||||
|
||||
describe("run_in_background parameter", () => {
|
||||
test("defaults to sync mode when run_in_background is omitted", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const executeSyncTaskSpy = spyOn(executor, "executeSyncTask").mockResolvedValue("sync result")
|
||||
const executeBackgroundTaskSpy = spyOn(executor, "executeBackgroundTask")
|
||||
spyOn(executor, "resolveSkillContent").mockResolvedValue({
|
||||
content: undefined,
|
||||
contents: [],
|
||||
error: null,
|
||||
})
|
||||
spyOn(executor, "resolveParentContext").mockResolvedValue({
|
||||
sessionID: "parent-session",
|
||||
agent: "sisyphus",
|
||||
})
|
||||
spyOn(executor, "resolveCategoryExecution").mockResolvedValue({
|
||||
agentToUse: "Sisyphus-Junior",
|
||||
categoryModel: undefined,
|
||||
categoryPromptAppend: undefined,
|
||||
modelInfo: undefined,
|
||||
actualModel: undefined,
|
||||
isUnstableAgent: false,
|
||||
fallbackChain: undefined,
|
||||
maxPromptTokens: undefined,
|
||||
})
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: { launch: async () => ({}) },
|
||||
client: { config: { get: async () => ({}) } },
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
const args: DelegateTaskArgsWithOptionalBackground = {
|
||||
description: "Default background flag",
|
||||
prompt: "Handle this task synchronously",
|
||||
category: "quick",
|
||||
load_skills: [],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await tool.execute(args as DelegateTaskArgs, toolContext)
|
||||
|
||||
// then
|
||||
expect(result).toBe("sync result")
|
||||
expect(executeSyncTaskSpy).toHaveBeenCalled()
|
||||
expect(executeBackgroundTaskSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("category delegation config validation", () => {
|
||||
test("fills subagent_type as sisyphus-junior when category is provided without subagent_type", async () => {
|
||||
// given
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||
description: tool.schema.string().describe("Short task description (3-5 words)"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
run_in_background: tool.schema.boolean().optional().default(false).describe("true=async (returns task_id), false=sync (waits). Default: false"),
|
||||
run_in_background: tool.schema.boolean().describe("true=async (returns task_id), false=sync (waits). Default: false"),
|
||||
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
|
||||
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
|
||||
@@ -122,6 +122,9 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
title: args.description,
|
||||
})
|
||||
|
||||
if (args.run_in_background === undefined) {
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`)
|
||||
}
|
||||
if (typeof args.load_skills === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(args.load_skills)
|
||||
@@ -137,8 +140,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
throw new Error(`Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.`)
|
||||
}
|
||||
|
||||
const runInBackgroundValue = args.run_in_background
|
||||
const runInBackground = runInBackgroundValue === true
|
||||
const runInBackground = args.run_in_background === true
|
||||
|
||||
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(args.load_skills, {
|
||||
gitMasterConfig: options.gitMasterConfig,
|
||||
@@ -198,21 +200,19 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
fallbackChain = resolution.fallbackChain
|
||||
maxPromptTokens = resolution.maxPromptTokens
|
||||
|
||||
const isRunInBackgroundFalse = runInBackgroundValue === false
|
||||
|| runInBackgroundValue === "false" as unknown as boolean
|
||||
|| runInBackgroundValue === undefined
|
||||
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
|
||||
|
||||
log("[task] unstable agent detection", {
|
||||
category: args.category,
|
||||
actualModel,
|
||||
isUnstableAgent,
|
||||
run_in_background_value: runInBackgroundValue,
|
||||
run_in_background_type: typeof runInBackgroundValue,
|
||||
isRunInBackgroundFalse,
|
||||
willForceBackground: isUnstableAgent && isRunInBackgroundFalse,
|
||||
run_in_background_value: args.run_in_background,
|
||||
run_in_background_type: typeof args.run_in_background,
|
||||
isRunInBackgroundExplicitlyFalse,
|
||||
willForceBackground: isUnstableAgent && isRunInBackgroundExplicitlyFalse,
|
||||
})
|
||||
|
||||
if (isUnstableAgent && isRunInBackgroundFalse) {
|
||||
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) {
|
||||
const systemContent = buildSystemContent({
|
||||
skillContent,
|
||||
skillContents,
|
||||
|
||||
@@ -192,6 +192,25 @@ describe("createHashlineEditTool", () => {
|
||||
expect(result).toContain("non-empty")
|
||||
})
|
||||
|
||||
it("treats replace with null lines as deletion", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "delete-line.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
const line2Hash = computeLineHash(2, "line2")
|
||||
|
||||
//#when
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: null }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline3")
|
||||
})
|
||||
|
||||
it("supports file rename with edits", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "source.txt")
|
||||
|
||||
Reference in New Issue
Block a user