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:
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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*/, "")
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user