Merge remote-tracking branch 'origin/dev' into feat/custom-agents

# Conflicts:
#	src/agents/utils.test.ts
#	src/plugin-handlers/agent-config-handler.ts
This commit is contained in:
edxeth
2026-02-26 18:53:29 +01:00
147 changed files with 6360 additions and 1763 deletions
@@ -0,0 +1,112 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { BackgroundTask } from "../../features/background-agent"
import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients"
import { createBackgroundOutput } from "./create-background-output"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
const mockContext = {
sessionID: "test-session",
messageID: "test-message",
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
} as unknown as ToolContext
function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-1",
parentSessionID: "main-1",
parentMessageID: "msg-1",
description: "background task",
prompt: "do work",
agent: "test-agent",
status: "running",
...overrides,
}
}
function createMockClient(): BackgroundOutputClient {
return {
session: {
messages: async () => ({ data: [] }),
},
}
}
describe("createBackgroundOutput block=true polling", () => {
test("returns terminal error output when task fails during blocking wait", async () => {
// #given
let pollCount = 0
const task = createTask({ status: "running" })
const manager: BackgroundOutputManager = {
getTask: (id: string) => {
if (id !== task.id) return undefined
pollCount += 1
if (pollCount >= 2) {
task.status = "error"
task.error = "task failed"
}
return task
},
}
const tool = createBackgroundOutput(manager, createMockClient())
// #when
const output = await tool.execute(
{
task_id: task.id,
block: true,
timeout: 3000,
full_session: false,
},
mockContext
)
// #then
expect(pollCount).toBeGreaterThanOrEqual(2)
expect(output).toContain("Status | **error**")
expect(output).not.toContain("Timed out waiting")
})
test("returns latest output with timeout note when task stays running", async () => {
// #given
let pollCount = 0
const task = createTask({ status: "running" })
const manager: BackgroundOutputManager = {
getTask: (id: string) => {
if (id !== task.id) return undefined
pollCount += 1
return task
},
}
const tool = createBackgroundOutput(manager, createMockClient())
// #when
const output = await tool.execute(
{
task_id: task.id,
block: true,
timeout: 10,
},
mockContext
)
// #then
expect(pollCount).toBeGreaterThanOrEqual(2)
expect(output).toContain("# Full Session Output")
expect(output).toContain("Timed out waiting")
expect(output).toContain("still running")
})
})
@@ -33,6 +33,14 @@ function formatResolvedTitle(task: BackgroundTask): string {
return `${label} - ${task.description}`
}
function isTaskActiveStatus(status: BackgroundTask["status"]): boolean {
return status === "pending" || status === "running"
}
function appendTimeoutNote(output: string, timeoutMs: number): string {
return `${output}\n\n> **Timed out waiting** after ${timeoutMs}ms. Task is still running; showing latest available output.`
}
export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
return tool({
description: BACKGROUND_OUTPUT_DESCRIPTION,
@@ -83,7 +91,9 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
let resolvedTask = task
if (shouldBlock && (task.status === "pending" || task.status === "running")) {
let didTimeoutWhileActive = false
if (shouldBlock && isTaskActiveStatus(task.status)) {
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
await delay(1000)
@@ -93,30 +103,39 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
return `Task was deleted: ${args.task_id}`
}
if (currentTask.status !== "pending" && currentTask.status !== "running") {
resolvedTask = currentTask
resolvedTask = currentTask
if (!isTaskActiveStatus(currentTask.status)) {
break
}
}
const finalCheck = manager.getTask(args.task_id)
if (finalCheck) {
resolvedTask = finalCheck
if (isTaskActiveStatus(resolvedTask.status)) {
const finalCheck = manager.getTask(args.task_id)
if (finalCheck) {
resolvedTask = finalCheck
}
}
if (isTaskActiveStatus(resolvedTask.status)) {
didTimeoutWhileActive = true
}
}
const isActive = resolvedTask.status === "pending" || resolvedTask.status === "running"
const isActive = isTaskActiveStatus(resolvedTask.status)
const includeThinking = isActive || (args.include_thinking ?? false)
const includeToolResults = isActive || (args.include_tool_results ?? false)
if (fullSession) {
return await formatFullSession(resolvedTask, client, {
const output = await formatFullSession(resolvedTask, client, {
includeThinking,
messageLimit: args.message_limit,
sinceMessageId: args.since_message_id,
includeToolResults,
thinkingMaxChars: args.thinking_max_chars,
})
return didTimeoutWhileActive ? appendTimeoutNote(output, timeoutMs) : output
}
if (resolvedTask.status === "completed") {
@@ -127,7 +146,8 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
return formatTaskStatus(resolvedTask)
}
return formatTaskStatus(resolvedTask)
const statusOutput = formatTaskStatus(resolvedTask)
return didTimeoutWhileActive ? appendTimeoutNote(statusOutput, timeoutMs) : statusOutput
} catch (error) {
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
}
@@ -14,6 +14,7 @@ export interface CategoryResolutionResult {
agentToUse: string
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
categoryPromptAppend: string | undefined
maxPromptTokens?: number
modelInfo: ModelFallbackInfo | undefined
actualModel: string | undefined
isUnstableAgent: boolean
@@ -51,6 +52,7 @@ export async function resolveCategoryExecution(
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -68,6 +70,7 @@ Available categories: ${allCategoryNames}`,
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -111,6 +114,7 @@ Available categories: ${allCategoryNames}`,
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -154,6 +158,7 @@ Available categories: ${allCategoryNames}`,
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -177,6 +182,7 @@ Available categories: ${categoryNames.join(", ")}`,
agentToUse: SISYPHUS_JUNIOR_AGENT,
categoryModel,
categoryPromptAppend,
maxPromptTokens: resolved.config.max_prompt_tokens,
modelInfo,
actualModel,
isUnstableAgent,
+2 -2
View File
@@ -208,10 +208,10 @@ You are NOT an interactive assistant. You are an autonomous problem-solver.
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3-pro", variant: "high" },
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
artistry: { model: "google/gemini-3-pro", variant: "high" },
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
quick: { model: "anthropic/claude-haiku-4-5" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
+32 -18
View File
@@ -1,5 +1,21 @@
import type { BuildSystemContentInput } from "./types"
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
import { buildSystemContentWithTokenLimit } from "./token-limiter"
const FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT = 24000
function usesFreeOrLocalModel(model: { providerID: string; modelID: string; variant?: string } | undefined): boolean {
if (!model) {
return false
}
const provider = model.providerID.toLowerCase()
const modelId = model.modelID.toLowerCase()
return provider.includes("local")
|| provider === "ollama"
|| provider === "lmstudio"
|| modelId.includes("free")
}
/**
* Build the system content to inject into the agent prompt.
@@ -8,7 +24,11 @@ import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
const {
skillContent,
skillContents,
categoryPromptAppend,
agentsContext,
maxPromptTokens,
model,
agentName,
availableCategories,
availableSkills,
@@ -18,23 +38,17 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
? buildPlanAgentSystemPrepend(availableCategories, availableSkills)
: ""
if (!skillContent && !categoryPromptAppend && !planAgentPrepend) {
return undefined
}
const effectiveMaxPromptTokens = maxPromptTokens
?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined)
const parts: string[] = []
if (planAgentPrepend) {
parts.push(planAgentPrepend)
}
if (skillContent) {
parts.push(skillContent)
}
if (categoryPromptAppend) {
parts.push(categoryPromptAppend)
}
return parts.join("\n\n") || undefined
return buildSystemContentWithTokenLimit(
{
skillContent,
skillContents,
categoryPromptAppend,
agentsContext: agentsContext ?? planAgentPrepend,
planAgentPrepend,
},
effectiveMaxPromptTokens
)
}
+5 -4
View File
@@ -5,17 +5,18 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string>, directory?: string }
): Promise<{ content: string | undefined; error: string | null }> {
): Promise<{ content: string | undefined; contents: string[]; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, error: null }
return { content: undefined, contents: [], error: null }
}
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true, directory: options?.directory })
const available = allSkills.map(s => s.name).join(", ")
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
return { content: undefined, contents: [], error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
}
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
const contents = Array.from(resolved.values())
return { content: contents.join("\n\n"), contents, error: null }
}
@@ -0,0 +1,121 @@
declare const require: (name: string) => unknown
const { describe, test, expect } = require("bun:test") as {
describe: (name: string, fn: () => void) => void
test: (name: string, fn: () => void) => void
expect: (value: unknown) => {
toBe: (expected: unknown) => void
toContain: (expected: string) => void
not: {
toContain: (expected: string) => void
}
toBeLessThanOrEqual: (expected: number) => void
toBeUndefined: () => void
}
}
import {
buildSystemContentWithTokenLimit,
estimateTokenCount,
truncateToTokenBudget,
} from "./token-limiter"
describe("token-limiter", () => {
test("estimateTokenCount uses 1 token per 4 chars approximation", () => {
// given
const text = "12345678"
// when
const result = estimateTokenCount(text)
// then
expect(result).toBe(2)
})
test("truncateToTokenBudget keeps text within requested token budget", () => {
// given
const content = "A".repeat(120)
const maxTokens = 10
// when
const result = truncateToTokenBudget(content, maxTokens)
// then
expect(estimateTokenCount(result)).toBeLessThanOrEqual(maxTokens)
})
test("buildSystemContentWithTokenLimit returns undefined when there is no content", () => {
// given
const input = {
skillContent: undefined,
skillContents: [],
categoryPromptAppend: undefined,
agentsContext: undefined,
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 20)
// then
expect(result).toBeUndefined()
})
test("buildSystemContentWithTokenLimit truncates skills before category and agents context", () => {
// given
const input = {
skillContents: [
"SKILL_ALPHA:" + "a".repeat(180),
"SKILL_BETA:" + "b".repeat(180),
],
categoryPromptAppend: "CATEGORY_APPEND:keep",
agentsContext: "AGENTS_CONTEXT:keep",
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 80)
// then
expect(result).toContain("AGENTS_CONTEXT:keep")
expect(result).toContain("CATEGORY_APPEND:keep")
expect(result).toContain("SKILL_ALPHA:")
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(80)
})
test("buildSystemContentWithTokenLimit truncates category after skills are exhausted", () => {
// given
const input = {
skillContents: ["SKILL_ALPHA:" + "a".repeat(220)],
categoryPromptAppend: "CATEGORY_APPEND:" + "c".repeat(220),
agentsContext: "AGENTS_CONTEXT:keep",
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 30)
// then
expect(result).toContain("AGENTS_CONTEXT:keep")
expect(result).not.toContain("SKILL_ALPHA:" + "a".repeat(80))
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(30)
})
test("buildSystemContentWithTokenLimit truncates agents context last", () => {
// given
const input = {
skillContents: ["SKILL_ALPHA:" + "a".repeat(220)],
categoryPromptAppend: "CATEGORY_APPEND:" + "c".repeat(220),
agentsContext: "AGENTS_CONTEXT:" + "g".repeat(220),
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 10)
// then
expect(result).toContain("AGENTS_CONTEXT:")
expect(result).not.toContain("SKILL_ALPHA:")
expect(result).not.toContain("CATEGORY_APPEND:")
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(10)
})
})
+117
View File
@@ -0,0 +1,117 @@
import type { BuildSystemContentInput } from "./types"
const CHARACTERS_PER_TOKEN = 4
export function estimateTokenCount(text: string): number {
if (!text) {
return 0
}
return Math.ceil(text.length / CHARACTERS_PER_TOKEN)
}
export function truncateToTokenBudget(content: string, maxTokens: number): string {
if (!content || maxTokens <= 0) {
return ""
}
const maxCharacters = maxTokens * CHARACTERS_PER_TOKEN
if (content.length <= maxCharacters) {
return content
}
return content.slice(0, maxCharacters)
}
function joinSystemParts(parts: string[]): string | undefined {
const filtered = parts.filter((part) => part.trim().length > 0)
if (filtered.length === 0) {
return undefined
}
return filtered.join("\n\n")
}
function reduceSegmentToFitBudget(content: string, overflowTokens: number): string {
if (overflowTokens <= 0 || !content) {
return content
}
const currentTokens = estimateTokenCount(content)
const nextBudget = Math.max(0, currentTokens - overflowTokens)
return truncateToTokenBudget(content, nextBudget)
}
export function buildSystemContentWithTokenLimit(
input: BuildSystemContentInput,
maxTokens: number | undefined
): string | undefined {
const skillParts = input.skillContents?.length
? [...input.skillContents]
: input.skillContent
? [input.skillContent]
: []
const categoryPromptAppend = input.categoryPromptAppend ?? ""
const agentsContext = input.agentsContext ?? input.planAgentPrepend ?? ""
if (maxTokens === undefined) {
return joinSystemParts([agentsContext, ...skillParts, categoryPromptAppend])
}
let nextSkills = [...skillParts]
let nextCategoryPromptAppend = categoryPromptAppend
let nextAgentsContext = agentsContext
const buildCurrentContent = (): string | undefined =>
joinSystemParts([nextAgentsContext, ...nextSkills, nextCategoryPromptAppend])
let systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
let overflowTokens = estimateTokenCount(systemContent) - maxTokens
if (overflowTokens > 0) {
for (let index = 0; index < nextSkills.length && overflowTokens > 0; index += 1) {
const skill = nextSkills[index]
const reducedSkill = reduceSegmentToFitBudget(skill, overflowTokens)
nextSkills[index] = reducedSkill
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
overflowTokens = estimateTokenCount(systemContent) - maxTokens
}
nextSkills = nextSkills.filter((skill) => skill.trim().length > 0)
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
overflowTokens = estimateTokenCount(systemContent) - maxTokens
}
if (overflowTokens > 0 && nextCategoryPromptAppend) {
nextCategoryPromptAppend = reduceSegmentToFitBudget(nextCategoryPromptAppend, overflowTokens)
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
overflowTokens = estimateTokenCount(systemContent) - maxTokens
}
if (overflowTokens > 0 && nextAgentsContext) {
nextAgentsContext = reduceSegmentToFitBudget(nextAgentsContext, overflowTokens)
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
}
if (!systemContent) {
return undefined
}
return truncateToTokenBudget(systemContent, maxTokens)
}
+14 -14
View File
@@ -17,7 +17,7 @@ const TEST_AVAILABLE_MODELS = new Set([
"anthropic/claude-opus-4-6",
"anthropic/claude-sonnet-4-6",
"anthropic/claude-haiku-4-5",
"google/gemini-3-pro",
"google/gemini-3.1-pro",
"google/gemini-3-flash",
"openai/gpt-5.2",
"openai/gpt-5.3-codex",
@@ -52,7 +52,7 @@ describe("sisyphus-task", () => {
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: {
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
google: ["gemini-3-pro", "gemini-3-flash"],
google: ["gemini-3.1-pro", "gemini-3-flash"],
openai: ["gpt-5.2", "gpt-5.3-codex"],
},
connected: ["anthropic", "google", "openai"],
@@ -73,7 +73,7 @@ describe("sisyphus-task", () => {
// when / #then
expect(category).toBeDefined()
expect(category.model).toBe("google/gemini-3-pro")
expect(category.model).toBe("google/gemini-3.1-pro")
expect(category.variant).toBe("high")
})
@@ -781,7 +781,7 @@ describe("sisyphus-task", () => {
// then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
expect(result!.promptAppend).toContain("VISUAL/UI")
})
@@ -805,7 +805,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
prompt_append: "Custom instructions here",
},
}
@@ -845,7 +845,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
temperature: 0.3,
},
}
@@ -868,7 +868,7 @@ describe("sisyphus-task", () => {
// then - category's built-in model wins over inheritedModel
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
})
test("systemDefaultModel is used as fallback when custom category has no model", () => {
@@ -910,7 +910,7 @@ describe("sisyphus-task", () => {
// then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
})
})
@@ -1738,7 +1738,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
@@ -2001,7 +2001,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
@@ -2028,7 +2028,7 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// when - artistry category (gemini-3-pro with high variant)
// when - artistry category (gemini-3.1-pro with high variant)
const result = await tool.execute(
{
description: "Test artistry forced background",
@@ -3026,9 +3026,9 @@ describe("sisyphus-task", () => {
// when resolveCategoryConfig is called
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// then should use category's built-in model (gemini-3-pro for visual-engineering)
// then should use category's built-in model (gemini-3.1-pro for visual-engineering)
expect(resolved).not.toBeNull()
expect(resolved!.model).toBe("google/gemini-3-pro")
expect(resolved!.model).toBe("google/gemini-3.1-pro")
})
test("systemDefaultModel is used when no other model is available", () => {
@@ -3522,7 +3522,7 @@ describe("sisyphus-task", () => {
)
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
// oracle fallback chain: gpt-5.2 (openai) > gemini-3-pro (google) > claude-opus-4-6 (anthropic)
// oracle fallback chain: gpt-5.2 (openai) > gemini-3.1-pro (google) > claude-opus-4-6 (anthropic)
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
expect(promptBody.model).toBeDefined()
expect(promptBody.model.providerID).toBe("openai")
+9 -1
View File
@@ -142,7 +142,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const runInBackground = args.run_in_background === true
const { content: skillContent, error: skillError } = await resolveSkillContent(args.load_skills, {
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(args.load_skills, {
gitMasterConfig: options.gitMasterConfig,
browserProvider: options.browserProvider,
disabledSkills: options.disabledSkills,
@@ -184,6 +184,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
let actualModel: string | undefined
let isUnstableAgent = false
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
let maxPromptTokens: number | undefined
if (args.category) {
const resolution = await resolveCategoryExecution(args, options, inheritedModel, systemDefaultModel)
@@ -197,6 +198,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
actualModel = resolution.actualModel
isUnstableAgent = resolution.isUnstableAgent
fallbackChain = resolution.fallbackChain
maxPromptTokens = resolution.maxPromptTokens
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
@@ -213,8 +215,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) {
const systemContent = buildSystemContent({
skillContent,
skillContents,
categoryPromptAppend,
agentName: agentToUse,
maxPromptTokens,
model: categoryModel,
availableCategories,
availableSkills,
})
@@ -239,8 +244,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const systemContent = buildSystemContent({
skillContent,
skillContents,
categoryPromptAppend,
agentName: agentToUse,
maxPromptTokens,
model: categoryModel,
availableCategories,
availableSkills,
})
+5
View File
@@ -72,7 +72,12 @@ export interface DelegateTaskToolOptions {
export interface BuildSystemContentInput {
skillContent?: string
skillContents?: string[]
categoryPromptAppend?: string
agentsContext?: string
planAgentPrepend?: string
maxPromptTokens?: number
model?: { providerID: string; modelID: string; variant?: string }
agentName?: string
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]
@@ -41,6 +41,23 @@ describe("generateUnifiedDiff", () => {
expect(diff).toContain(" line 13")
})
it("limits each hunk to three context lines", () => {
//#given
const oldContent = createNumberedLines(20)
const newLines = oldContent.split("\n")
newLines[9] = "line 10 updated"
const newContent = newLines.join("\n")
//#when
const diff = generateUnifiedDiff(oldContent, newContent, "sample.txt")
//#then
expect(diff).toContain(" line 7")
expect(diff).toContain(" line 13")
expect(diff).not.toContain(" line 6")
expect(diff).not.toContain(" line 14")
})
it("returns a diff string for identical content", () => {
//#given
const oldContent = "alpha\nbeta\ngamma"
+1 -1
View File
@@ -16,7 +16,7 @@ export function toHashlineContent(content: string): string {
}
export function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string {
return createTwoFilesPatch(filePath, filePath, oldContent, newContent)
return createTwoFilesPatch(filePath, filePath, oldContent, newContent, undefined, undefined, { context: 3 })
}
export function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } {
@@ -1,18 +1,24 @@
import type { HashlineEdit } from "./types"
import { toNewLines } from "./edit-text-normalization"
import { normalizeLineRef } from "./validation"
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
function canonicalAnchor(anchor: string | undefined): string {
if (!anchor) return ""
return normalizeLineRef(anchor)
}
function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.op) {
case "replace":
return `replace|${edit.pos}|${edit.end ?? ""}|${normalizeEditPayload(edit.lines)}`
return `replace|${canonicalAnchor(edit.pos)}|${edit.end ? canonicalAnchor(edit.end) : ""}|${normalizeEditPayload(edit.lines)}`
case "append":
return `append|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
return `append|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
case "prepend":
return `prepend|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
return `prepend|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
default:
return JSON.stringify(edit)
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { applyHashlineEdits } from "./edit-operations"
import { applyHashlineEdits, applyHashlineEditsWithReport } from "./edit-operations"
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
@@ -389,3 +389,23 @@ describe("hashline edit operations", () => {
expect(result).toEqual("replaced A\nline 3\nreplaced B")
})
})
describe("dedupe anchor canonicalization", () => {
it("deduplicates edits with whitespace-variant anchors", () => {
//#given
const content = "line 1\nline 2"
const lines = content.split("\n")
const canonical = `1#${computeLineHash(1, lines[0])}`
const spaced = ` 1 # ${computeLineHash(1, lines[0])} `
//#when
const report = applyHashlineEditsWithReport(content, [
{ op: "append", pos: canonical, lines: ["inserted"] },
{ op: "append", pos: spaced, lines: ["inserted"] },
])
//#then
expect(report.deduplicatedEdits).toBe(1)
expect(report.content).toBe("line 1\ninserted\nline 2")
})
})
@@ -33,7 +33,7 @@ function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
if (edits.length === 0) return false
return edits.every((edit) => edit.op === "append" || edit.op === "prepend")
return edits.every((edit) => (edit.op === "append" || edit.op === "prepend") && !edit.pos)
}
function buildSuccessMeta(
@@ -86,19 +86,19 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
const filePath = args.filePath
const { delete: deleteMode, rename } = args
if (deleteMode && rename) {
return "Error: delete and rename cannot be used together"
}
if (deleteMode && args.edits.length > 0) {
return "Error: delete mode requires edits to be an empty array"
}
if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
if (deleteMode && rename) {
return "Error: delete and rename cannot be used together"
}
if (deleteMode && edits.length > 0) {
return "Error: delete mode requires edits to be an empty array"
}
const file = Bun.file(filePath)
const exists = await file.exists()
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
+1 -1
View File
@@ -10,7 +10,7 @@ WORKFLOW:
VALIDATION:
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
Each edit must be one of: replace, append, prepend
Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines"?: string|string[]|null }
Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines": string|string[]|null }
lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
+77
View File
@@ -341,4 +341,81 @@ describe("createHashlineEditTool", () => {
//#then
expect(envelope.lineEnding).toBe("\r\n")
})
it("rejects delete=true with non-empty edits before normalization", async () => {
//#given
const filePath = path.join(tempDir, "delete-reject.txt")
fs.writeFileSync(filePath, "line1")
//#when
const result = await tool.execute(
{
filePath,
delete: true,
edits: [{ op: "replace", pos: "1#ZZ", lines: "bad" }],
},
createMockContext(),
)
//#then
expect(result).toContain("delete mode requires edits to be an empty array")
expect(fs.existsSync(filePath)).toBe(true)
})
it("rejects delete=true combined with rename", async () => {
//#given
const filePath = path.join(tempDir, "delete-rename.txt")
fs.writeFileSync(filePath, "line1")
//#when
const result = await tool.execute(
{
filePath,
delete: true,
rename: path.join(tempDir, "new-name.txt"),
edits: [],
},
createMockContext(),
)
//#then
expect(result).toContain("delete and rename cannot be used together")
expect(fs.existsSync(filePath)).toBe(true)
})
it("rejects missing file creation with anchored append", async () => {
//#given
const filePath = path.join(tempDir, "nonexistent.txt")
//#when
const result = await tool.execute(
{
filePath,
edits: [{ op: "append", pos: "1#ZZ", lines: ["bad"] }],
},
createMockContext(),
)
//#then
expect(result).toContain("File not found")
})
it("allows missing file creation with unanchored append", async () => {
//#given
const filePath = path.join(tempDir, "newfile.txt")
//#when
const result = await tool.execute(
{
filePath,
edits: [{ op: "append", lines: ["created"] }],
},
createMockContext(),
)
//#then
expect(fs.existsSync(filePath)).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("created")
expect(result).toBe(`Updated ${filePath}`)
})
})
-1
View File
@@ -31,7 +31,6 @@ export function createHashlineEditTool(): ToolDefinition {
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
lines: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string()), tool.schema.null()])
.optional()
.describe("Replacement or inserted lines. null/[] deletes with replace"),
})
)
+1 -1
View File
@@ -15,7 +15,7 @@ const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
function normalizeLineRef(ref: string): string {
export function normalizeLineRef(ref: string): string {
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
+2 -1
View File
@@ -1,4 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./tmux-path-resolver"
@@ -89,7 +90,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
}
const proc = Bun.spawn([tmuxPath, ...parts], {
const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
stdout: "pipe",
stderr: "pipe",
})