fix(delegate-task): Wave 1 - fix polling timeout, resource cleanup, tool restrictions, idle dedup, auth-plugins JSONC, CLI runner hang
- fix(delegate-task): return error on poll timeout instead of silent null - fix(delegate-task): ensure toast and session cleanup on all error paths with try/finally - fix(delegate-task): apply agent tool restrictions in sync-prompt-sender - fix(plugin): add symmetric idle dedup to prevent double hook triggers - fix(cli): replace regex-based JSONC editing with jsonc-parser in auth-plugins - fix(cli): abort event stream after completion and restore no-timeout default All changes verified with tests and typecheck.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "node:fs"
|
||||
import { parseJsonc } from "../../shared/jsonc-parser"
|
||||
import type { InstallConfig } from "../types"
|
||||
import { resetConfigContext } from "./config-context"
|
||||
|
||||
let testConfigPath: string
|
||||
let testConfigDir: string
|
||||
let testCounter = 0
|
||||
let fetchVersionSpy: unknown
|
||||
|
||||
beforeEach(async () => {
|
||||
testCounter++
|
||||
testConfigDir = join(tmpdir(), `test-opencode-${Date.now()}-${testCounter}`)
|
||||
testConfigPath = join(testConfigDir, "opencode.jsonc")
|
||||
mkdirSync(testConfigDir, { recursive: true })
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = testConfigDir
|
||||
resetConfigContext()
|
||||
|
||||
const module = await import("./auth-plugins")
|
||||
fetchVersionSpy = spyOn(module, "fetchLatestVersion").mockResolvedValue("1.2.3")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(testConfigDir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const testConfig: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: true,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
describe("addAuthPlugins", () => {
|
||||
describe("Test 1: JSONC with commented plugin line", () => {
|
||||
it("preserves comment, updates actual plugin array", async () => {
|
||||
const content = `{
|
||||
// "plugin": ["old-plugin"]
|
||||
"plugin": ["existing-plugin"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(result.configPath, "utf-8")
|
||||
expect(newContent).toContain('// "plugin": ["old-plugin"]')
|
||||
expect(newContent).toContain('existing-plugin')
|
||||
expect(newContent).toContain('opencode-antigravity-auth')
|
||||
|
||||
const parsed = parseJsonc<Record<string, unknown>>(newContent)
|
||||
const plugins = parsed.plugin as string[]
|
||||
expect(plugins).toContain('existing-plugin')
|
||||
expect(plugins.some((p) => p.startsWith('opencode-antigravity-auth'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 2: Plugin array already contains antigravity", () => {
|
||||
it("does not add duplicate", async () => {
|
||||
const content = `{
|
||||
"plugin": ["existing-plugin", "opencode-antigravity-auth"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(testConfigPath, "utf-8")
|
||||
const parsed = parseJsonc<Record<string, unknown>>(newContent)
|
||||
const plugins = parsed.plugin as string[]
|
||||
|
||||
const antigravityCount = plugins.filter((p) => p.startsWith('opencode-antigravity-auth')).length
|
||||
expect(antigravityCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 3: Backup created before write", () => {
|
||||
it("creates .bak file", async () => {
|
||||
const originalContent = `{
|
||||
"plugin": ["existing-plugin"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, originalContent, "utf-8")
|
||||
readFileSync(testConfigPath, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(existsSync(`${result.configPath}.bak`)).toBe(true)
|
||||
|
||||
const backupContent = readFileSync(`${result.configPath}.bak`, "utf-8")
|
||||
expect(backupContent).toBe(originalContent)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 4: Comment with } character", () => {
|
||||
it("preserves comments with special characters", async () => {
|
||||
const content = `{
|
||||
// This comment has } special characters
|
||||
"plugin": ["existing-plugin"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(testConfigPath, "utf-8")
|
||||
expect(newContent).toContain('// This comment has } special characters')
|
||||
|
||||
expect(() => parseJsonc(newContent)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 5: Comment containing 'plugin' string", () => {
|
||||
it("must NOT match comment location", async () => {
|
||||
const content = `{
|
||||
// "plugin": ["fake"]
|
||||
"plugin": ["existing-plugin"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(testConfigPath, "utf-8")
|
||||
expect(newContent).toContain('// "plugin": ["fake"]')
|
||||
|
||||
const parsed = parseJsonc<Record<string, unknown>>(newContent)
|
||||
const plugins = parsed.plugin as string[]
|
||||
expect(plugins).toContain('existing-plugin')
|
||||
expect(plugins).not.toContain('fake')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 6: No existing plugin array", () => {
|
||||
it("creates plugin array when none exists", async () => {
|
||||
const content = `{
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(result.configPath, "utf-8")
|
||||
|
||||
const parsed = parseJsonc<Record<string, unknown>>(newContent)
|
||||
expect(parsed).toHaveProperty('plugin')
|
||||
const plugins = parsed.plugin as string[]
|
||||
expect(plugins.some((p) => p.startsWith('opencode-antigravity-auth'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 7: Post-write validation ensures valid JSONC", () => {
|
||||
it("result file must be valid JSONC", async () => {
|
||||
const content = `{
|
||||
"plugin": ["existing-plugin"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(testConfigPath, "utf-8")
|
||||
expect(() => parseJsonc(newContent)).not.toThrow()
|
||||
|
||||
const parsed = parseJsonc<Record<string, unknown>>(newContent)
|
||||
expect(parsed).toHaveProperty('plugin')
|
||||
expect(parsed).toHaveProperty('provider')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test 8: Multiple plugins in array", () => {
|
||||
it("appends to existing plugins", async () => {
|
||||
const content = `{
|
||||
"plugin": ["plugin-1", "plugin-2", "plugin-3"],
|
||||
"provider": {}
|
||||
}`
|
||||
writeFileSync(testConfigPath, content, "utf-8")
|
||||
|
||||
const { addAuthPlugins } = await import("./auth-plugins")
|
||||
const result = await addAuthPlugins(testConfig)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const newContent = readFileSync(result.configPath, "utf-8")
|
||||
const parsed = parseJsonc<Record<string, unknown>>(newContent)
|
||||
const plugins = parsed.plugin as string[]
|
||||
|
||||
expect(plugins).toContain('plugin-1')
|
||||
expect(plugins).toContain('plugin-2')
|
||||
expect(plugins).toContain('plugin-3')
|
||||
expect(plugins.some((p) => p.startsWith('opencode-antigravity-auth'))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { readFileSync, writeFileSync, copyFileSync } from "node:fs"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import type { ConfigMergeResult, InstallConfig } from "../types"
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||
import { parseJsonc } from "../../shared/jsonc-parser"
|
||||
|
||||
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
|
||||
try {
|
||||
@@ -59,21 +61,24 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
|
||||
if (format === "jsonc") {
|
||||
const content = readFileSync(path, "utf-8")
|
||||
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
|
||||
const match = content.match(pluginArrayRegex)
|
||||
|
||||
if (match) {
|
||||
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
|
||||
const newContent = content.replace(
|
||||
pluginArrayRegex,
|
||||
`"plugin": [\n ${formattedPlugins}\n ]`
|
||||
)
|
||||
writeFileSync(path, newContent)
|
||||
} else {
|
||||
const inlinePlugins = plugins.map((p) => `"${p}"`).join(", ")
|
||||
const newContent = content.replace(/(\{)/, `$1\n "plugin": [${inlinePlugins}],`)
|
||||
writeFileSync(path, newContent)
|
||||
copyFileSync(path, `${path}.bak`)
|
||||
|
||||
const newContent = applyEdits(
|
||||
content,
|
||||
modify(content, ["plugin"], plugins, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
parseJsonc(newContent)
|
||||
} catch (error) {
|
||||
copyFileSync(`${path}.bak`, path)
|
||||
throw new Error(`Generated JSONC is invalid: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
writeFileSync(path, newContent)
|
||||
} else {
|
||||
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { pollForCompletion } from "./poll-for-completion"
|
||||
|
||||
export { resolveRunAgent }
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
|
||||
const DEFAULT_TIMEOUT_MS = 0
|
||||
|
||||
export async function run(options: RunOptions): Promise<number> {
|
||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||
@@ -79,11 +79,14 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
console.log(pc.dim("Waiting for completion...\n"))
|
||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||
console.log(pc.dim("Waiting for completion...\n"))
|
||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||
|
||||
await eventProcessor.catch(() => {})
|
||||
cleanup()
|
||||
// Abort the event stream to stop the processor
|
||||
abortController.abort()
|
||||
|
||||
await eventProcessor.catch(() => {})
|
||||
cleanup()
|
||||
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
|
||||
Reference in New Issue
Block a user