Merge branch 'dev' into fix/1700-vertex-anthropic
This commit is contained in:
commit
b7497d0f9f
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -56,6 +56,7 @@ jobs:
|
|||||||
bun test src/cli/doctor/format-default.test.ts
|
bun test src/cli/doctor/format-default.test.ts
|
||||||
bun test src/tools/call-omo-agent/sync-executor.test.ts
|
bun test src/tools/call-omo-agent/sync-executor.test.ts
|
||||||
bun test src/tools/call-omo-agent/session-creator.test.ts
|
bun test src/tools/call-omo-agent/session-creator.test.ts
|
||||||
|
bun test src/tools/session-manager
|
||||||
bun test src/features/opencode-skill-loader/loader.test.ts
|
bun test src/features/opencode-skill-loader/loader.test.ts
|
||||||
|
|
||||||
- name: Run remaining tests
|
- name: Run remaining tests
|
||||||
@ -63,7 +64,7 @@ jobs:
|
|||||||
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
|
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
|
||||||
# that were already run in isolation above.
|
# that were already run in isolation above.
|
||||||
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
|
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
|
||||||
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
|
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts, session-manager (all)
|
||||||
bun test bin script src/config src/mcp src/index.test.ts \
|
bun test bin script src/config src/mcp src/index.test.ts \
|
||||||
src/agents src/shared \
|
src/agents src/shared \
|
||||||
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
|
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
|
||||||
@ -72,7 +73,7 @@ jobs:
|
|||||||
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
|
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
|
||||||
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
|
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
|
||||||
src/tools/glob src/tools/grep src/tools/interactive-bash \
|
src/tools/glob src/tools/grep src/tools/interactive-bash \
|
||||||
src/tools/look-at src/tools/lsp src/tools/session-manager \
|
src/tools/look-at src/tools/lsp \
|
||||||
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
|
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
|
||||||
src/tools/call-omo-agent/background-agent-executor.test.ts \
|
src/tools/call-omo-agent/background-agent-executor.test.ts \
|
||||||
src/tools/call-omo-agent/background-executor.test.ts \
|
src/tools/call-omo-agent/background-executor.test.ts \
|
||||||
|
|||||||
@ -98,7 +98,8 @@
|
|||||||
"stop-continuation-guard",
|
"stop-continuation-guard",
|
||||||
"tasks-todowrite-disabler",
|
"tasks-todowrite-disabler",
|
||||||
"write-existing-file-guard",
|
"write-existing-file-guard",
|
||||||
"anthropic-effort"
|
"anthropic-effort",
|
||||||
|
"hashline-read-enhancer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -2830,6 +2831,9 @@
|
|||||||
},
|
},
|
||||||
"safe_hook_creation": {
|
"safe_hook_creation": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"hashline_edit": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
28
bun.lock
28
bun.lock
@ -28,13 +28,13 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.5.5",
|
"oh-my-opencode-darwin-arm64": "3.6.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.5.5",
|
"oh-my-opencode-darwin-x64": "3.6.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.5.5",
|
"oh-my-opencode-linux-arm64": "3.6.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.5.5",
|
"oh-my-opencode-linux-arm64-musl": "3.6.0",
|
||||||
"oh-my-opencode-linux-x64": "3.5.5",
|
"oh-my-opencode-linux-x64": "3.6.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.5.5",
|
"oh-my-opencode-linux-x64-musl": "3.6.0",
|
||||||
"oh-my-opencode-windows-x64": "3.5.5",
|
"oh-my-opencode-windows-x64": "3.6.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -226,19 +226,19 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="],
|
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.6.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-JkyJC3b9ueRgSyPJMjTKlBO99gIyTpI87lEV5Tk7CBv6TFbj2ZFxfaA8mEm138NbwmYa/Z4Rf7I5tZyp2as93A=="],
|
||||||
|
|
||||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="],
|
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.6.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5HsXz3F42T6CmPk6IW+pErJVSmPnqc3Gc1OntoKp/b4FwuWkFJh9kftDSH3cnKTX98H6XBqnwZoFKCNCiiVLEA=="],
|
||||||
|
|
||||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="],
|
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.6.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KjCSC2i9XdjzGsX6coP9xwj7naxTpdqnB53TiLbVH+KeF0X0dNsVV7PHbme3I1orjjzYoEbVYVC3ZNaleubzog=="],
|
||||||
|
|
||||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="],
|
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.6.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-EARvFQXnkqSnwPpKtghmoV5e/JmweJXhjcOrRNvEwQ8HSb4FIhdRmJkTw4Z/EzyoIRTQcY019ALOiBbdIiOUEA=="],
|
||||||
|
|
||||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="],
|
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.6.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jYyew4NKAOM6NrMM0+LlRlz6s1EVMI9cQdK/o0t8uqFheZVeb7u4cBZwwfhJ79j7EWkSWGc0Jdj9G2dOukbDxg=="],
|
||||||
|
|
||||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="],
|
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.6.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BrR+JftCXP/il04q2uImWIueCiuTmXbivsXYkfFONdO1Rq9b4t0BVua9JIYk7l3OUfeRlrKlFNYNfpFhvVADOw=="],
|
||||||
|
|
||||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="],
|
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.6.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-cIYQYzcQGhGFE99ulHGXs8S1vDHjgCtT3ID2dDoOztnOQW0ZVa61oCHlkBtjdP/BEv2tH5AGvKrXAICXs19iFw=="],
|
||||||
|
|
||||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test"
|
import { describe, it, expect, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"
|
||||||
import type { RunResult } from "./types"
|
import type { RunResult } from "./types"
|
||||||
import { createJsonOutputManager } from "./json-output"
|
import { createJsonOutputManager } from "./json-output"
|
||||||
import { resolveSession } from "./session-resolver"
|
import { resolveSession } from "./session-resolver"
|
||||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient } from "./types"
|
||||||
|
import * as originalSdk from "@opencode-ai/sdk"
|
||||||
|
import * as originalPortUtils from "../../shared/port-utils"
|
||||||
|
|
||||||
const mockServerClose = mock(() => {})
|
const mockServerClose = mock(() => {})
|
||||||
const mockCreateOpencode = mock(() =>
|
const mockCreateOpencode = mock(() =>
|
||||||
@ -27,6 +29,11 @@ mock.module("../../shared/port-utils", () => ({
|
|||||||
DEFAULT_SERVER_PORT: 4096,
|
DEFAULT_SERVER_PORT: 4096,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.module("@opencode-ai/sdk", () => originalSdk)
|
||||||
|
mock.module("../../shared/port-utils", () => originalPortUtils)
|
||||||
|
})
|
||||||
|
|
||||||
const { createServerConnection } = await import("./server-connection")
|
const { createServerConnection } = await import("./server-connection")
|
||||||
|
|
||||||
interface MockWriteStream {
|
interface MockWriteStream {
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test"
|
||||||
|
|
||||||
|
import * as originalSdk from "@opencode-ai/sdk"
|
||||||
|
import * as originalPortUtils from "../../shared/port-utils"
|
||||||
|
|
||||||
const originalConsole = globalThis.console
|
const originalConsole = globalThis.console
|
||||||
|
|
||||||
@ -25,6 +28,11 @@ mock.module("../../shared/port-utils", () => ({
|
|||||||
DEFAULT_SERVER_PORT: 4096,
|
DEFAULT_SERVER_PORT: 4096,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.module("@opencode-ai/sdk", () => originalSdk)
|
||||||
|
mock.module("../../shared/port-utils", () => originalPortUtils)
|
||||||
|
})
|
||||||
|
|
||||||
const { createServerConnection } = await import("./server-connection")
|
const { createServerConnection } = await import("./server-connection")
|
||||||
|
|
||||||
describe("createServerConnection", () => {
|
describe("createServerConnection", () => {
|
||||||
|
|||||||
@ -553,6 +553,18 @@ describe("BrowserAutomationProviderSchema", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("accepts 'playwright-cli' as valid provider", () => {
|
||||||
|
// given
|
||||||
|
const input = "playwright-cli"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = BrowserAutomationProviderSchema.safeParse(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).toBe("playwright-cli")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("BrowserAutomationConfigSchema", () => {
|
describe("BrowserAutomationConfigSchema", () => {
|
||||||
@ -577,6 +589,17 @@ describe("BrowserAutomationConfigSchema", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result.provider).toBe("agent-browser")
|
expect(result.provider).toBe("agent-browser")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("accepts playwright-cli provider in config", () => {
|
||||||
|
// given
|
||||||
|
const input = { provider: "playwright-cli" }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = BrowserAutomationConfigSchema.parse(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.provider).toBe("playwright-cli")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => {
|
describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => {
|
||||||
@ -607,6 +630,18 @@ describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => {
|
|||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.data?.browser_automation_engine).toBeUndefined()
|
expect(result.data?.browser_automation_engine).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("accepts browser_automation_engine with playwright-cli", () => {
|
||||||
|
// given
|
||||||
|
const input = { browser_automation_engine: { provider: "playwright-cli" } }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = OhMyOpenCodeConfigSchema.safeParse(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.browser_automation_engine?.provider).toBe("playwright-cli")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ExperimentalConfigSchema feature flags", () => {
|
describe("ExperimentalConfigSchema feature flags", () => {
|
||||||
@ -663,6 +698,59 @@ describe("ExperimentalConfigSchema feature flags", () => {
|
|||||||
expect(result.data.safe_hook_creation).toBeUndefined()
|
expect(result.data.safe_hook_creation).toBeUndefined()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("accepts hashline_edit as true", () => {
|
||||||
|
//#given
|
||||||
|
const config = { hashline_edit: true }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = ExperimentalConfigSchema.safeParse(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.hashline_edit).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts hashline_edit as false", () => {
|
||||||
|
//#given
|
||||||
|
const config = { hashline_edit: false }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = ExperimentalConfigSchema.safeParse(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.hashline_edit).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hashline_edit is optional", () => {
|
||||||
|
//#given
|
||||||
|
const config = { safe_hook_creation: true }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = ExperimentalConfigSchema.safeParse(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.hashline_edit).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects non-boolean hashline_edit", () => {
|
||||||
|
//#given
|
||||||
|
const config = { hashline_edit: "true" }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = ExperimentalConfigSchema.safeParse(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("GitMasterConfigSchema", () => {
|
describe("GitMasterConfigSchema", () => {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const BrowserAutomationProviderSchema = z.enum([
|
|||||||
"playwright",
|
"playwright",
|
||||||
"agent-browser",
|
"agent-browser",
|
||||||
"dev-browser",
|
"dev-browser",
|
||||||
|
"playwright-cli",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const BrowserAutomationConfigSchema = z.object({
|
export const BrowserAutomationConfigSchema = z.object({
|
||||||
@ -12,6 +13,7 @@ export const BrowserAutomationConfigSchema = z.object({
|
|||||||
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
|
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
|
||||||
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
|
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
|
||||||
* - "dev-browser": Uses dev-browser skill with persistent browser state
|
* - "dev-browser": Uses dev-browser skill with persistent browser state
|
||||||
|
* - "playwright-cli": Uses Playwright CLI (@playwright/cli) - token-efficient CLI alternative
|
||||||
*/
|
*/
|
||||||
provider: BrowserAutomationProviderSchema.default("playwright"),
|
provider: BrowserAutomationProviderSchema.default("playwright"),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -15,6 +15,8 @@ export const ExperimentalConfigSchema = z.object({
|
|||||||
plugin_load_timeout_ms: z.number().min(1000).optional(),
|
plugin_load_timeout_ms: z.number().min(1000).optional(),
|
||||||
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
|
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
|
||||||
safe_hook_creation: z.boolean().optional(),
|
safe_hook_creation: z.boolean().optional(),
|
||||||
|
/** Enable hashline_edit tool for improved file editing with hash-based line anchors */
|
||||||
|
hashline_edit: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export const HookNameSchema = z.enum([
|
|||||||
"tasks-todowrite-disabler",
|
"tasks-todowrite-disabler",
|
||||||
"write-existing-file-guard",
|
"write-existing-file-guard",
|
||||||
"anthropic-effort",
|
"anthropic-effort",
|
||||||
|
"hashline-read-enhancer",
|
||||||
])
|
])
|
||||||
|
|
||||||
export type HookName = z.infer<typeof HookNameSchema>
|
export type HookName = z.infer<typeof HookNameSchema>
|
||||||
|
|||||||
@ -22,8 +22,9 @@ export function createManagers(args: {
|
|||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
tmuxConfig: TmuxConfig
|
tmuxConfig: TmuxConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
|
backgroundNotificationHookEnabled: boolean
|
||||||
}): Managers {
|
}): Managers {
|
||||||
const { ctx, pluginConfig, tmuxConfig, modelCacheState } = args
|
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args
|
||||||
|
|
||||||
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig)
|
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig)
|
||||||
|
|
||||||
@ -57,6 +58,7 @@ export function createManagers(args: {
|
|||||||
log("[index] tmux cleanup error during shutdown:", error)
|
log("[index] tmux cleanup error during shutdown:", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
enableParentSessionNotifications: backgroundNotificationHookEnabled,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1003,6 +1003,52 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||||
|
test("should skip parent prompt injection when notifications are disabled", async () => {
|
||||||
|
//#given
|
||||||
|
let promptCalled = false
|
||||||
|
const promptMock = async () => {
|
||||||
|
promptCalled = true
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
prompt: promptMock,
|
||||||
|
promptAsync: promptMock,
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager(
|
||||||
|
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||||
|
undefined,
|
||||||
|
{ enableParentSessionNotifications: false },
|
||||||
|
)
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-no-parent-notification",
|
||||||
|
sessionID: "session-child",
|
||||||
|
parentSessionID: "session-parent",
|
||||||
|
parentMessageID: "msg-parent",
|
||||||
|
description: "task notifications disabled",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
}
|
||||||
|
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||||
|
.notifyParentSession(task)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(promptCalled).toBe(false)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function buildNotificationPromptBody(
|
function buildNotificationPromptBody(
|
||||||
task: BackgroundTask,
|
task: BackgroundTask,
|
||||||
currentMessage: CurrentMessage | null
|
currentMessage: CurrentMessage | null
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export class BackgroundManager {
|
|||||||
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||||
|
private enableParentSessionNotifications: boolean
|
||||||
readonly taskHistory = new TaskHistory()
|
readonly taskHistory = new TaskHistory()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -101,6 +102,7 @@ export class BackgroundManager {
|
|||||||
tmuxConfig?: TmuxConfig
|
tmuxConfig?: TmuxConfig
|
||||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||||
onShutdown?: () => void
|
onShutdown?: () => void
|
||||||
|
enableParentSessionNotifications?: boolean
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.tasks = new Map()
|
this.tasks = new Map()
|
||||||
@ -113,6 +115,7 @@ export class BackgroundManager {
|
|||||||
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
||||||
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
||||||
this.onShutdown = options?.onShutdown
|
this.onShutdown = options?.onShutdown
|
||||||
|
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
|
||||||
this.registerProcessCleanup()
|
this.registerProcessCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1203,19 +1206,22 @@ export class BackgroundManager {
|
|||||||
allComplete = true
|
allComplete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
|
const completedTasks = allComplete
|
||||||
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
|
? Array.from(this.tasks.values())
|
||||||
|
|
||||||
let notification: string
|
|
||||||
let completedTasks: BackgroundTask[] = []
|
|
||||||
if (allComplete) {
|
|
||||||
completedTasks = Array.from(this.tasks.values())
|
|
||||||
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
|
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
|
||||||
const completedTasksText = completedTasks
|
: []
|
||||||
.map(t => `- \`${t.id}\`: ${t.description}`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
notification = `<system-reminder>
|
if (this.enableParentSessionNotifications) {
|
||||||
|
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
|
||||||
|
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
|
||||||
|
|
||||||
|
let notification: string
|
||||||
|
if (allComplete) {
|
||||||
|
const completedTasksText = completedTasks
|
||||||
|
.map(t => `- \`${t.id}\`: ${t.description}`)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
notification = `<system-reminder>
|
||||||
[ALL BACKGROUND TASKS COMPLETE]
|
[ALL BACKGROUND TASKS COMPLETE]
|
||||||
|
|
||||||
**Completed:**
|
**Completed:**
|
||||||
@ -1223,9 +1229,9 @@ ${completedTasksText || `- \`${task.id}\`: ${task.description}`}
|
|||||||
|
|
||||||
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||||
</system-reminder>`
|
</system-reminder>`
|
||||||
} else {
|
} else {
|
||||||
// Individual completion - silent notification
|
// Individual completion - silent notification
|
||||||
notification = `<system-reminder>
|
notification = `<system-reminder>
|
||||||
[BACKGROUND TASK ${statusText}]
|
[BACKGROUND TASK ${statusText}]
|
||||||
**ID:** \`${task.id}\`
|
**ID:** \`${task.id}\`
|
||||||
**Description:** ${task.description}
|
**Description:** ${task.description}
|
||||||
@ -1236,70 +1242,76 @@ Do NOT poll - continue productive work.
|
|||||||
|
|
||||||
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
|
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
|
||||||
</system-reminder>`
|
</system-reminder>`
|
||||||
}
|
}
|
||||||
|
|
||||||
let agent: string | undefined = task.parentAgent
|
let agent: string | undefined = task.parentAgent
|
||||||
let model: { providerID: string; modelID: string } | undefined
|
let model: { providerID: string; modelID: string } | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
|
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
|
||||||
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
|
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
|
||||||
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
||||||
}>)
|
}>)
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const info = messages[i].info
|
const info = messages[i].info
|
||||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||||
agent = info.agent ?? task.parentAgent
|
agent = info.agent ?? task.parentAgent
|
||||||
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isAbortedSessionError(error)) {
|
||||||
|
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
|
||||||
|
taskId: task.id,
|
||||||
|
parentSessionID: task.parentSessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const messageDir = getMessageDir(task.parentSessionID)
|
||||||
|
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||||
|
agent = currentMessage?.agent ?? task.parentAgent
|
||||||
|
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||||
|
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[background-agent] notifyParentSession context:", {
|
||||||
|
taskId: task.id,
|
||||||
|
resolvedAgent: agent,
|
||||||
|
resolvedModel: model,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.session.promptAsync({
|
||||||
|
path: { id: task.parentSessionID },
|
||||||
|
body: {
|
||||||
|
noReply: !allComplete,
|
||||||
|
...(agent !== undefined ? { agent } : {}),
|
||||||
|
...(model !== undefined ? { model } : {}),
|
||||||
|
...(task.parentTools ? { tools: task.parentTools } : {}),
|
||||||
|
parts: [{ type: "text", text: notification }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
log("[background-agent] Sent notification to parent session:", {
|
||||||
|
taskId: task.id,
|
||||||
|
allComplete,
|
||||||
|
noReply: !allComplete,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isAbortedSessionError(error)) {
|
||||||
|
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
|
||||||
|
taskId: task.id,
|
||||||
|
parentSessionID: task.parentSessionID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log("[background-agent] Failed to send notification:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} else {
|
||||||
if (this.isAbortedSessionError(error)) {
|
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
|
||||||
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
|
|
||||||
taskId: task.id,
|
|
||||||
parentSessionID: task.parentSessionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const messageDir = getMessageDir(task.parentSessionID)
|
|
||||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
|
||||||
agent = currentMessage?.agent ?? task.parentAgent
|
|
||||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
|
||||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[background-agent] notifyParentSession context:", {
|
|
||||||
taskId: task.id,
|
|
||||||
resolvedAgent: agent,
|
|
||||||
resolvedModel: model,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.client.session.promptAsync({
|
|
||||||
path: { id: task.parentSessionID },
|
|
||||||
body: {
|
|
||||||
noReply: !allComplete,
|
|
||||||
...(agent !== undefined ? { agent } : {}),
|
|
||||||
...(model !== undefined ? { model } : {}),
|
|
||||||
...(task.parentTools ? { tools: task.parentTools } : {}),
|
|
||||||
parts: [{ type: "text", text: notification }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
log("[background-agent] Sent notification to parent session:", {
|
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
allComplete,
|
parentSessionID: task.parentSessionID,
|
||||||
noReply: !allComplete,
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
|
||||||
if (this.isAbortedSessionError(error)) {
|
|
||||||
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
|
|
||||||
taskId: task.id,
|
|
||||||
parentSessionID: task.parentSessionID,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
log("[background-agent] Failed to send notification:", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allComplete) {
|
if (allComplete) {
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { resolveParentDirectory } from "./parent-directory-resolver"
|
||||||
|
|
||||||
|
describe("background-agent parent-directory-resolver", () => {
|
||||||
|
const originalPlatform = process.platform
|
||||||
|
|
||||||
|
test("uses current working directory on Windows when parent session directory is AppData", async () => {
|
||||||
|
//#given
|
||||||
|
Object.defineProperty(process, "platform", { value: "win32" })
|
||||||
|
try {
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({
|
||||||
|
data: { directory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveParentDirectory({
|
||||||
|
client: client as Parameters<typeof resolveParentDirectory>[0]["client"],
|
||||||
|
parentSessionID: "ses_parent",
|
||||||
|
defaultDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(process.cwd())
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpencodeClient } from "../constants"
|
import type { OpencodeClient } from "../constants"
|
||||||
import { log } from "../../../shared"
|
import { log, resolveSessionDirectory } from "../../../shared"
|
||||||
|
|
||||||
export async function resolveParentDirectory(options: {
|
export async function resolveParentDirectory(options: {
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
@ -15,7 +15,10 @@ export async function resolveParentDirectory(options: {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const parentDirectory = parentSession?.data?.directory ?? defaultDirectory
|
const parentDirectory = resolveSessionDirectory({
|
||||||
|
parentDirectory: parentSession?.data?.directory,
|
||||||
|
fallbackDirectory: defaultDirectory,
|
||||||
|
})
|
||||||
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
||||||
return parentDirectory
|
return parentDirectory
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,4 +140,35 @@ describe("createBuiltinSkills", () => {
|
|||||||
// #then
|
// #then
|
||||||
expect(skills.length).toBe(4)
|
expect(skills.length).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("returns playwright-cli skill when browserProvider is 'playwright-cli'", () => {
|
||||||
|
// given
|
||||||
|
const options = { browserProvider: "playwright-cli" as const }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const skills = createBuiltinSkills(options)
|
||||||
|
|
||||||
|
// then
|
||||||
|
const playwrightSkill = skills.find((s) => s.name === "playwright")
|
||||||
|
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||||
|
expect(playwrightSkill).toBeDefined()
|
||||||
|
expect(playwrightSkill!.description).toContain("browser")
|
||||||
|
expect(playwrightSkill!.allowedTools).toContain("Bash(playwright-cli:*)")
|
||||||
|
expect(playwrightSkill!.mcpConfig).toBeUndefined()
|
||||||
|
expect(agentBrowserSkill).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("playwright-cli skill template contains CLI commands", () => {
|
||||||
|
// given
|
||||||
|
const options = { browserProvider: "playwright-cli" as const }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const skills = createBuiltinSkills(options)
|
||||||
|
const skill = skills.find((s) => s.name === "playwright")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(skill!.template).toContain("playwright-cli open")
|
||||||
|
expect(skill!.template).toContain("playwright-cli snapshot")
|
||||||
|
expect(skill!.template).toContain("playwright-cli click")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { BrowserAutomationProvider } from "../../config/schema"
|
|||||||
import {
|
import {
|
||||||
playwrightSkill,
|
playwrightSkill,
|
||||||
agentBrowserSkill,
|
agentBrowserSkill,
|
||||||
|
playwrightCliSkill,
|
||||||
frontendUiUxSkill,
|
frontendUiUxSkill,
|
||||||
gitMasterSkill,
|
gitMasterSkill,
|
||||||
devBrowserSkill,
|
devBrowserSkill,
|
||||||
@ -17,7 +18,14 @@ export interface CreateBuiltinSkillsOptions {
|
|||||||
export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): BuiltinSkill[] {
|
export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): BuiltinSkill[] {
|
||||||
const { browserProvider = "playwright", disabledSkills } = options
|
const { browserProvider = "playwright", disabledSkills } = options
|
||||||
|
|
||||||
const browserSkill = browserProvider === "agent-browser" ? agentBrowserSkill : playwrightSkill
|
let browserSkill: BuiltinSkill
|
||||||
|
if (browserProvider === "agent-browser") {
|
||||||
|
browserSkill = agentBrowserSkill
|
||||||
|
} else if (browserProvider === "playwright-cli") {
|
||||||
|
browserSkill = playwrightCliSkill
|
||||||
|
} else {
|
||||||
|
browserSkill = playwrightSkill
|
||||||
|
}
|
||||||
|
|
||||||
const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill]
|
const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill]
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export { playwrightSkill, agentBrowserSkill } from "./playwright"
|
export { playwrightSkill, agentBrowserSkill } from "./playwright"
|
||||||
|
export { playwrightCliSkill } from "./playwright-cli"
|
||||||
export { frontendUiUxSkill } from "./frontend-ui-ux"
|
export { frontendUiUxSkill } from "./frontend-ui-ux"
|
||||||
export { gitMasterSkill } from "./git-master"
|
export { gitMasterSkill } from "./git-master"
|
||||||
export { devBrowserSkill } from "./dev-browser"
|
export { devBrowserSkill } from "./dev-browser"
|
||||||
|
|||||||
268
src/features/builtin-skills/skills/playwright-cli.ts
Normal file
268
src/features/builtin-skills/skills/playwright-cli.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import type { BuiltinSkill } from "../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright CLI skill — token-efficient CLI alternative to the MCP-based playwright skill.
|
||||||
|
*
|
||||||
|
* Uses name "playwright" (not "playwright-cli") because agents hardcode "playwright" as the
|
||||||
|
* canonical browser skill name. The browserProvider config swaps the implementation behind
|
||||||
|
* the same name: "playwright" gives MCP, "playwright-cli" gives this CLI variant.
|
||||||
|
* The binary is still called `playwright-cli` (see allowedTools).
|
||||||
|
*/
|
||||||
|
export const playwrightCliSkill: BuiltinSkill = {
|
||||||
|
name: "playwright",
|
||||||
|
description: "MUST USE for any browser-related tasks. Browser automation via playwright-cli - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||||
|
template: `# Browser Automation with playwright-cli
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# open new browser
|
||||||
|
playwright-cli open
|
||||||
|
# navigate to a page
|
||||||
|
playwright-cli goto https://playwright.dev
|
||||||
|
# interact with the page using refs from the snapshot
|
||||||
|
playwright-cli click e15
|
||||||
|
playwright-cli type "page.click"
|
||||||
|
playwright-cli press Enter
|
||||||
|
# take a screenshot
|
||||||
|
playwright-cli screenshot
|
||||||
|
# close the browser
|
||||||
|
playwright-cli close
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli open
|
||||||
|
# open and navigate right away
|
||||||
|
playwright-cli open https://example.com/
|
||||||
|
playwright-cli goto https://playwright.dev
|
||||||
|
playwright-cli type "search query"
|
||||||
|
playwright-cli click e3
|
||||||
|
playwright-cli dblclick e7
|
||||||
|
playwright-cli fill e5 "user@example.com"
|
||||||
|
playwright-cli drag e2 e8
|
||||||
|
playwright-cli hover e4
|
||||||
|
playwright-cli select e9 "option-value"
|
||||||
|
playwright-cli upload ./document.pdf
|
||||||
|
playwright-cli check e12
|
||||||
|
playwright-cli uncheck e12
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli snapshot --filename=after-click.yaml
|
||||||
|
playwright-cli eval "document.title"
|
||||||
|
playwright-cli eval "el => el.textContent" e5
|
||||||
|
playwright-cli dialog-accept
|
||||||
|
playwright-cli dialog-accept "confirmation text"
|
||||||
|
playwright-cli dialog-dismiss
|
||||||
|
playwright-cli resize 1920 1080
|
||||||
|
playwright-cli close
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli go-back
|
||||||
|
playwright-cli go-forward
|
||||||
|
playwright-cli reload
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli press Enter
|
||||||
|
playwright-cli press ArrowDown
|
||||||
|
playwright-cli keydown Shift
|
||||||
|
playwright-cli keyup Shift
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Mouse
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli mousemove 150 300
|
||||||
|
playwright-cli mousedown
|
||||||
|
playwright-cli mousedown right
|
||||||
|
playwright-cli mouseup
|
||||||
|
playwright-cli mouseup right
|
||||||
|
playwright-cli mousewheel 0 100
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Save as
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli screenshot
|
||||||
|
playwright-cli screenshot e5
|
||||||
|
playwright-cli screenshot --filename=page.png
|
||||||
|
playwright-cli pdf --filename=page.pdf
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli tab-list
|
||||||
|
playwright-cli tab-new
|
||||||
|
playwright-cli tab-new https://example.com/page
|
||||||
|
playwright-cli tab-close
|
||||||
|
playwright-cli tab-close 2
|
||||||
|
playwright-cli tab-select 0
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli state-save
|
||||||
|
playwright-cli state-save auth.json
|
||||||
|
playwright-cli state-load auth.json
|
||||||
|
|
||||||
|
# Cookies
|
||||||
|
playwright-cli cookie-list
|
||||||
|
playwright-cli cookie-list --domain=example.com
|
||||||
|
playwright-cli cookie-get session_id
|
||||||
|
playwright-cli cookie-set session_id abc123
|
||||||
|
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||||
|
playwright-cli cookie-delete session_id
|
||||||
|
playwright-cli cookie-clear
|
||||||
|
|
||||||
|
# LocalStorage
|
||||||
|
playwright-cli localstorage-list
|
||||||
|
playwright-cli localstorage-get theme
|
||||||
|
playwright-cli localstorage-set theme dark
|
||||||
|
playwright-cli localstorage-delete theme
|
||||||
|
playwright-cli localstorage-clear
|
||||||
|
|
||||||
|
# SessionStorage
|
||||||
|
playwright-cli sessionstorage-list
|
||||||
|
playwright-cli sessionstorage-get step
|
||||||
|
playwright-cli sessionstorage-set step 3
|
||||||
|
playwright-cli sessionstorage-delete step
|
||||||
|
playwright-cli sessionstorage-clear
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli route "**/*.jpg" --status=404
|
||||||
|
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||||
|
playwright-cli route-list
|
||||||
|
playwright-cli unroute "**/*.jpg"
|
||||||
|
playwright-cli unroute
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### DevTools
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli console
|
||||||
|
playwright-cli console warning
|
||||||
|
playwright-cli network
|
||||||
|
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
playwright-cli video-start
|
||||||
|
playwright-cli video-stop video.webm
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli install --skills
|
||||||
|
playwright-cli install-browser
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
\`\`\`bash
|
||||||
|
# Use specific browser when creating session
|
||||||
|
playwright-cli open --browser=chrome
|
||||||
|
playwright-cli open --browser=firefox
|
||||||
|
playwright-cli open --browser=webkit
|
||||||
|
playwright-cli open --browser=msedge
|
||||||
|
# Connect to browser via extension
|
||||||
|
playwright-cli open --extension
|
||||||
|
|
||||||
|
# Use persistent profile (by default profile is in-memory)
|
||||||
|
playwright-cli open --persistent
|
||||||
|
# Use persistent profile with custom directory
|
||||||
|
playwright-cli open --profile=/path/to/profile
|
||||||
|
|
||||||
|
# Start with config file
|
||||||
|
playwright-cli open --config=my-config.json
|
||||||
|
|
||||||
|
# Close the browser
|
||||||
|
playwright-cli close
|
||||||
|
# Delete user data for the default session
|
||||||
|
playwright-cli delete-data
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Browser Sessions
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# create new browser session named "mysession" with persistent profile
|
||||||
|
playwright-cli -s=mysession open example.com --persistent
|
||||||
|
# same with manually specified profile directory (use when requested explicitly)
|
||||||
|
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||||
|
playwright-cli -s=mysession click e6
|
||||||
|
playwright-cli -s=mysession close # stop a named browser
|
||||||
|
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||||
|
|
||||||
|
playwright-cli list
|
||||||
|
# Close all browsers
|
||||||
|
playwright-cli close-all
|
||||||
|
# Forcefully kill all browser processes
|
||||||
|
playwright-cli kill-all
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Example: Form submission
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli open https://example.com/form
|
||||||
|
playwright-cli snapshot
|
||||||
|
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
playwright-cli click e3
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Example: Multi-tab workflow
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli tab-new https://example.com/other
|
||||||
|
playwright-cli tab-list
|
||||||
|
playwright-cli tab-select 0
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Example: Debugging with DevTools
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli click e4
|
||||||
|
playwright-cli fill e7 "test"
|
||||||
|
playwright-cli console
|
||||||
|
playwright-cli network
|
||||||
|
playwright-cli close
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli click e4
|
||||||
|
playwright-cli fill e7 "test"
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
playwright-cli close
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Specific tasks
|
||||||
|
|
||||||
|
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||||
|
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||||
|
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||||
|
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||||
|
* **Test generation** [references/test-generation.md](references/test-generation.md)
|
||||||
|
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||||
|
* **Video recording** [references/video-recording.md](references/video-recording.md)`,
|
||||||
|
allowedTools: ["Bash(playwright-cli:*)"],
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { ExperimentalConfig } from "../../config"
|
import type { ExperimentalConfig } from "../../config"
|
||||||
|
import * as originalDeduplicationRecovery from "./deduplication-recovery"
|
||||||
|
|
||||||
const attemptDeduplicationRecoveryMock = mock(async () => {})
|
const attemptDeduplicationRecoveryMock = mock(async () => {})
|
||||||
|
|
||||||
@ -8,6 +9,10 @@ mock.module("./deduplication-recovery", () => ({
|
|||||||
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
|
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.module("./deduplication-recovery", () => originalDeduplicationRecovery)
|
||||||
|
})
|
||||||
|
|
||||||
function createImmediateTimeouts(): () => void {
|
function createImmediateTimeouts(): () => void {
|
||||||
const originalSetTimeout = globalThis.setTimeout
|
const originalSetTimeout = globalThis.setTimeout
|
||||||
const originalClearTimeout = globalThis.clearTimeout
|
const originalClearTimeout = globalThis.clearTimeout
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
|
||||||
import { truncateUntilTargetTokens } from "./storage"
|
import { truncateUntilTargetTokens } from "./storage"
|
||||||
import * as storage from "./storage"
|
import * as storage from "./storage"
|
||||||
|
|
||||||
@ -11,6 +11,10 @@ mock.module("./storage", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.module("./storage", () => storage)
|
||||||
|
})
|
||||||
|
|
||||||
describe("truncateUntilTargetTokens", () => {
|
describe("truncateUntilTargetTokens", () => {
|
||||||
const sessionID = "test-session"
|
const sessionID = "test-session"
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it, mock } from "bun:test"
|
import { describe, expect, it, afterAll, mock } from "bun:test"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import type { Todo } from "@opencode-ai/sdk"
|
||||||
import { createCompactionTodoPreserverHook } from "./index"
|
import { createCompactionTodoPreserverHook } from "./index"
|
||||||
|
|
||||||
const updateMock = mock(async () => {})
|
const updateMock = mock(async () => {})
|
||||||
@ -10,27 +12,37 @@ mock.module("opencode/session/todo", () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
type TodoSnapshot = {
|
afterAll(() => {
|
||||||
id: string
|
mock.module("opencode/session/todo", () => ({
|
||||||
content: string
|
Todo: {
|
||||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
update: async () => {},
|
||||||
priority?: "low" | "medium" | "high"
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockContext(todoResponses: TodoSnapshot[][]): PluginInput {
|
|
||||||
let callIndex = 0
|
|
||||||
return {
|
|
||||||
client: {
|
|
||||||
session: {
|
|
||||||
todo: async () => {
|
|
||||||
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
|
|
||||||
callIndex += 1
|
|
||||||
return { data: current }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function createMockContext(todoResponses: Array<Todo>[]): PluginInput {
|
||||||
|
let callIndex = 0
|
||||||
|
|
||||||
|
const client = createOpencodeClient({ directory: "/tmp/test" })
|
||||||
|
type SessionTodoOptions = Parameters<typeof client.session.todo>[0]
|
||||||
|
type SessionTodoResult = ReturnType<typeof client.session.todo>
|
||||||
|
|
||||||
|
const request = new Request("http://localhost")
|
||||||
|
const response = new Response()
|
||||||
|
client.session.todo = mock((_: SessionTodoOptions): SessionTodoResult => {
|
||||||
|
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
|
||||||
|
callIndex += 1
|
||||||
|
return Promise.resolve({ data: current, error: undefined, request, response })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
project: { id: "test-project", worktree: "/tmp/test", time: { created: Date.now() } },
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as PluginInput
|
worktree: "/tmp/test",
|
||||||
|
serverUrl: new URL("http://localhost"),
|
||||||
|
$: Bun.$,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("compaction-todo-preserver", () => {
|
describe("compaction-todo-preserver", () => {
|
||||||
@ -38,7 +50,7 @@ describe("compaction-todo-preserver", () => {
|
|||||||
//#given
|
//#given
|
||||||
updateMock.mockClear()
|
updateMock.mockClear()
|
||||||
const sessionID = "session-compaction-missing"
|
const sessionID = "session-compaction-missing"
|
||||||
const todos = [
|
const todos: Todo[] = [
|
||||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
|
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
|
||||||
]
|
]
|
||||||
@ -58,7 +70,7 @@ describe("compaction-todo-preserver", () => {
|
|||||||
//#given
|
//#given
|
||||||
updateMock.mockClear()
|
updateMock.mockClear()
|
||||||
const sessionID = "session-compaction-present"
|
const sessionID = "session-compaction-present"
|
||||||
const todos = [
|
const todos: Todo[] = [
|
||||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
]
|
]
|
||||||
const ctx = createMockContext([todos, todos])
|
const ctx = createMockContext([todos, todos])
|
||||||
|
|||||||
66
src/hooks/hashline-read-enhancer/hook.ts
Normal file
66
src/hooks/hashline-read-enhancer/hook.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
||||||
|
|
||||||
|
interface HashlineReadEnhancerConfig {
|
||||||
|
hashline_edit?: { enabled: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
const READ_LINE_PATTERN = /^(\d+): (.*)$/
|
||||||
|
|
||||||
|
function isReadTool(toolName: string): boolean {
|
||||||
|
return toolName.toLowerCase() === "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldProcess(config: HashlineReadEnhancerConfig): boolean {
|
||||||
|
return config.hashline_edit?.enabled ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextFile(output: string): boolean {
|
||||||
|
const firstLine = output.split("\n")[0] ?? ""
|
||||||
|
return READ_LINE_PATTERN.test(firstLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLine(line: string): string {
|
||||||
|
const match = READ_LINE_PATTERN.exec(line)
|
||||||
|
if (!match) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
const lineNumber = parseInt(match[1], 10)
|
||||||
|
const content = match[2]
|
||||||
|
const hash = computeLineHash(lineNumber, content)
|
||||||
|
return `${lineNumber}:${hash}|${content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformOutput(output: string): string {
|
||||||
|
if (!output) {
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
if (!isTextFile(output)) {
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
const lines = output.split("\n")
|
||||||
|
return lines.map(transformLine).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHashlineReadEnhancerHook(
|
||||||
|
_ctx: PluginInput,
|
||||||
|
config: HashlineReadEnhancerConfig
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
"tool.execute.after": async (
|
||||||
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
|
output: { title: string; output: string; metadata: unknown }
|
||||||
|
) => {
|
||||||
|
if (!isReadTool(input.tool)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof output.output !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!shouldProcess(config)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.output = transformOutput(output.output)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/hooks/hashline-read-enhancer/index.test.ts
Normal file
248
src/hooks/hashline-read-enhancer/index.test.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "bun:test"
|
||||||
|
import { createHashlineReadEnhancerHook } from "./hook"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
//#given - Test setup helpers
|
||||||
|
function createMockContext(): PluginInput {
|
||||||
|
return {
|
||||||
|
client: {} as unknown as PluginInput["client"],
|
||||||
|
directory: "/test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestConfig {
|
||||||
|
hashline_edit?: { enabled: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockConfig(enabled: boolean): TestConfig {
|
||||||
|
return {
|
||||||
|
hashline_edit: { enabled },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createHashlineReadEnhancerHook", () => {
|
||||||
|
let mockCtx: PluginInput
|
||||||
|
const sessionID = "test-session-123"
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCtx = createMockContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tool name matching", () => {
|
||||||
|
it("should process 'read' tool (lowercase)", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toContain("1:")
|
||||||
|
expect(output.output).toContain("|")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should process 'Read' tool (mixed case)", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "Read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toContain("|")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should process 'READ' tool (uppercase)", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "READ", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toContain("|")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should skip non-read tools", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "edit", sessionID, callID: "call-1" }
|
||||||
|
const originalOutput = "1: hello\n2: world"
|
||||||
|
const output = { title: "Edit", output: originalOutput, metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe(originalOutput)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("config flag check", () => {
|
||||||
|
it("should skip when hashline_edit is disabled", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(false))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const originalOutput = "1: hello\n2: world"
|
||||||
|
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe(originalOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should skip when hashline_edit config is missing", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, {})
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const originalOutput = "1: hello\n2: world"
|
||||||
|
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe(originalOutput)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("output transformation", () => {
|
||||||
|
it("should transform 'N: content' format to 'N:HASH|content'", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: function hello() {\n2: console.log('world')\n3: }", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const lines = output.output.split("\n")
|
||||||
|
expect(lines[0]).toMatch(/^1:[a-f0-9]{2}\|function hello\(\) \{$/)
|
||||||
|
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\| console\.log\('world'\)$/)
|
||||||
|
expect(lines[2]).toMatch(/^3:[a-f0-9]{2}\|\}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty output", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle single line", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: const x = 1", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toMatch(/^1:[a-f0-9]{2}\|const x = 1$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("binary file detection", () => {
|
||||||
|
it("should skip binary files (no line number prefix)", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const originalOutput = "PNG\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"
|
||||||
|
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe(originalOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should skip if first line doesn't match pattern", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const originalOutput = "some binary data\nmore data"
|
||||||
|
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe(originalOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should process if first line matches 'N: ' pattern", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: valid line\n2: another line", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toContain("|")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle non-string output gracefully", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: null as unknown as string, metadata: {} }
|
||||||
|
|
||||||
|
//#when - should not throw
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle lines with no content after colon", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: "1: hello\n2: \n3: world", metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const lines = output.output.split("\n")
|
||||||
|
expect(lines[0]).toMatch(/^1:[a-f0-9]{2}\|hello$/)
|
||||||
|
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\|$/)
|
||||||
|
expect(lines[2]).toMatch(/^3:[a-f0-9]{2}\|world$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle very long lines", async () => {
|
||||||
|
//#given
|
||||||
|
const longContent = "a".repeat(1000)
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||||
|
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||||
|
const output = { title: "Read", output: `1: ${longContent}`, metadata: {} }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toMatch(/^1:[a-f0-9]{2}\|a+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
1
src/hooks/hashline-read-enhancer/index.ts
Normal file
1
src/hooks/hashline-read-enhancer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { createHashlineReadEnhancerHook } from "./hook"
|
||||||
@ -43,3 +43,4 @@ export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
|
|||||||
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
|
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
|
||||||
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
|
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
|
||||||
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||||
|
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||||
|
|||||||
@ -44,6 +44,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
pluginConfig,
|
pluginConfig,
|
||||||
tmuxConfig,
|
tmuxConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
|
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolsResult = await createTools({
|
const toolsResult = await createTools({
|
||||||
|
|||||||
@ -23,6 +23,11 @@ type AgentConfigRecord = Record<string, Record<string, unknown> | undefined> & {
|
|||||||
plan?: Record<string, unknown>;
|
plan?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function hasConfiguredDefaultAgent(config: Record<string, unknown>): boolean {
|
||||||
|
const defaultAgent = config.default_agent;
|
||||||
|
return typeof defaultAgent === "string" && defaultAgent.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyAgentConfig(params: {
|
export async function applyAgentConfig(params: {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
pluginConfig: OhMyOpenCodeConfig;
|
pluginConfig: OhMyOpenCodeConfig;
|
||||||
@ -106,7 +111,10 @@ export async function applyAgentConfig(params: {
|
|||||||
const configAgent = params.config.agent as AgentConfigRecord | undefined;
|
const configAgent = params.config.agent as AgentConfigRecord | undefined;
|
||||||
|
|
||||||
if (isSisyphusEnabled && builtinAgents.sisyphus) {
|
if (isSisyphusEnabled && builtinAgents.sisyphus) {
|
||||||
(params.config as { default_agent?: string }).default_agent = getAgentDisplayName("sisyphus");
|
if (!hasConfiguredDefaultAgent(params.config)) {
|
||||||
|
(params.config as { default_agent?: string }).default_agent =
|
||||||
|
getAgentDisplayName("sisyphus");
|
||||||
|
}
|
||||||
|
|
||||||
const agentConfig: Record<string, unknown> = {
|
const agentConfig: Record<string, unknown> = {
|
||||||
sisyphus: builtinAgents.sisyphus,
|
sisyphus: builtinAgents.sisyphus,
|
||||||
|
|||||||
@ -349,6 +349,55 @@ describe("Agent permission defaults", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("default_agent behavior with Sisyphus orchestration", () => {
|
||||||
|
test("preserves existing default_agent when already set", async () => {
|
||||||
|
// #given
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
default_agent: "hephaestus",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(config.default_agent).toBe("hephaestus")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets default_agent to sisyphus when missing", async () => {
|
||||||
|
// #given
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(config.default_agent).toBe(getAgentDisplayName("sisyphus"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("Prometheus category config resolution", () => {
|
describe("Prometheus category config resolution", () => {
|
||||||
test("resolves ultrabrain category config", () => {
|
test("resolves ultrabrain category config", () => {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
createRulesInjectorHook,
|
createRulesInjectorHook,
|
||||||
createTasksTodowriteDisablerHook,
|
createTasksTodowriteDisablerHook,
|
||||||
createWriteExistingFileGuardHook,
|
createWriteExistingFileGuardHook,
|
||||||
|
createHashlineReadEnhancerHook,
|
||||||
} from "../../hooks"
|
} from "../../hooks"
|
||||||
import {
|
import {
|
||||||
getOpenCodeVersion,
|
getOpenCodeVersion,
|
||||||
@ -28,6 +29,7 @@ export type ToolGuardHooks = {
|
|||||||
rulesInjector: ReturnType<typeof createRulesInjectorHook> | null
|
rulesInjector: ReturnType<typeof createRulesInjectorHook> | null
|
||||||
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
|
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
|
||||||
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
|
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
|
||||||
|
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolGuardHooks(args: {
|
export function createToolGuardHooks(args: {
|
||||||
@ -85,6 +87,10 @@ export function createToolGuardHooks(args: {
|
|||||||
? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx))
|
? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer")
|
||||||
|
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? false } }))
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentChecker,
|
commentChecker,
|
||||||
toolOutputTruncator,
|
toolOutputTruncator,
|
||||||
@ -94,5 +100,6 @@ export function createToolGuardHooks(args: {
|
|||||||
rulesInjector,
|
rulesInjector,
|
||||||
tasksTodowriteDisabler,
|
tasksTodowriteDisabler,
|
||||||
writeExistingFileGuard,
|
writeExistingFileGuard,
|
||||||
|
hashlineReadEnhancer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,5 +43,6 @@ export function createToolExecuteAfterHandler(args: {
|
|||||||
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output)
|
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output)
|
||||||
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
|
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
|
||||||
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
|
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
|
||||||
|
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,6 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
|
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
|
||||||
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
|
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
|
||||||
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
|
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
|
||||||
|
|
||||||
if (input.tool === "task") {
|
if (input.tool === "task") {
|
||||||
const argsObject = output.args
|
const argsObject = output.args
|
||||||
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
|
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
createTaskGetTool,
|
createTaskGetTool,
|
||||||
createTaskList,
|
createTaskList,
|
||||||
createTaskUpdateTool,
|
createTaskUpdateTool,
|
||||||
|
createHashlineEditTool,
|
||||||
} from "../tools"
|
} from "../tools"
|
||||||
import { getMainSessionID } from "../features/claude-code-session-state"
|
import { getMainSessionID } from "../features/claude-code-session-state"
|
||||||
import { filterDisabledTools } from "../shared/disabled-tools"
|
import { filterDisabledTools } from "../shared/disabled-tools"
|
||||||
@ -48,7 +49,7 @@ export function createToolRegistry(args: {
|
|||||||
const { ctx, pluginConfig, managers, skillContext, availableCategories } = args
|
const { ctx, pluginConfig, managers, skillContext, availableCategories } = args
|
||||||
|
|
||||||
const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client)
|
const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client)
|
||||||
const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager)
|
const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager, pluginConfig.disabled_agents ?? [])
|
||||||
|
|
||||||
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
||||||
(agent) => agent.toLowerCase() === "multimodal-looker",
|
(agent) => agent.toLowerCase() === "multimodal-looker",
|
||||||
@ -117,6 +118,11 @@ export function createToolRegistry(args: {
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
const hashlineEnabled = pluginConfig.experimental?.hashline_edit ?? false
|
||||||
|
const hashlineToolsRecord: Record<string, ToolDefinition> = hashlineEnabled
|
||||||
|
? { edit: createHashlineEditTool() }
|
||||||
|
: {}
|
||||||
|
|
||||||
const allTools: Record<string, ToolDefinition> = {
|
const allTools: Record<string, ToolDefinition> = {
|
||||||
...builtinTools,
|
...builtinTools,
|
||||||
...createGrepTools(ctx),
|
...createGrepTools(ctx),
|
||||||
@ -132,6 +138,7 @@ export function createToolRegistry(args: {
|
|||||||
slashcommand: slashcommandTool,
|
slashcommand: slashcommandTool,
|
||||||
interactive_bash,
|
interactive_bash,
|
||||||
...taskToolsRecord,
|
...taskToolsRecord,
|
||||||
|
...hashlineToolsRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
||||||
|
|||||||
@ -54,3 +54,4 @@ export * from "./truncate-description"
|
|||||||
export * from "./opencode-storage-paths"
|
export * from "./opencode-storage-paths"
|
||||||
export * from "./opencode-message-dir"
|
export * from "./opencode-message-dir"
|
||||||
export * from "./normalize-sdk-response"
|
export * from "./normalize-sdk-response"
|
||||||
|
export * from "./session-directory-resolver"
|
||||||
|
|||||||
101
src/shared/session-directory-resolver.test.ts
Normal file
101
src/shared/session-directory-resolver.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { isWindowsAppDataDirectory, resolveSessionDirectory } from "./session-directory-resolver"
|
||||||
|
|
||||||
|
describe("session-directory-resolver", () => {
|
||||||
|
describe("isWindowsAppDataDirectory", () => {
|
||||||
|
test("returns true when path is under AppData Local", () => {
|
||||||
|
//#given
|
||||||
|
const directory = "C:/Users/test/AppData/Local/opencode"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = isWindowsAppDataDirectory(directory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns true when path ends with AppData directory segment", () => {
|
||||||
|
//#given
|
||||||
|
const directory = "C:/Users/test/AppData/Local"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = isWindowsAppDataDirectory(directory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when path is outside AppData", () => {
|
||||||
|
//#given
|
||||||
|
const directory = "D:/projects/oh-my-opencode"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = isWindowsAppDataDirectory(directory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for lookalike non-AppData segment", () => {
|
||||||
|
//#given
|
||||||
|
const directory = "D:/projects/appdata/local-tools"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = isWindowsAppDataDirectory(directory)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveSessionDirectory", () => {
|
||||||
|
test("uses process working directory on Windows when parent directory drifts to AppData", () => {
|
||||||
|
//#given
|
||||||
|
const options = {
|
||||||
|
parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop",
|
||||||
|
fallbackDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode",
|
||||||
|
platform: "win32" as const,
|
||||||
|
currentWorkingDirectory: "D:\\projects\\oh-my-opencode",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = resolveSessionDirectory(options)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("D:\\projects\\oh-my-opencode")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps AppData directory when current working directory is also AppData", () => {
|
||||||
|
//#given
|
||||||
|
const options = {
|
||||||
|
parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop",
|
||||||
|
fallbackDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode",
|
||||||
|
platform: "win32" as const,
|
||||||
|
currentWorkingDirectory: "C:\\Users\\test\\AppData\\Local\\Temp",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = resolveSessionDirectory(options)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps original directory outside Windows", () => {
|
||||||
|
//#given
|
||||||
|
const options = {
|
||||||
|
parentDirectory: "/tmp/opencode",
|
||||||
|
fallbackDirectory: "/workspace/project",
|
||||||
|
platform: "darwin" as const,
|
||||||
|
currentWorkingDirectory: "/workspace/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = resolveSessionDirectory(options)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("/tmp/opencode")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/shared/session-directory-resolver.ts
Normal file
41
src/shared/session-directory-resolver.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const WINDOWS_APPDATA_SEGMENTS = ["\\appdata\\local", "\\appdata\\roaming", "\\appdata\\locallow"]
|
||||||
|
|
||||||
|
function normalizeWindowsPath(directory: string): string {
|
||||||
|
return directory.replaceAll("/", "\\").toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWindowsAppDataDirectory(directory: string): boolean {
|
||||||
|
const normalizedDirectory = normalizeWindowsPath(directory)
|
||||||
|
return WINDOWS_APPDATA_SEGMENTS.some((segment) => {
|
||||||
|
return normalizedDirectory.endsWith(segment) || normalizedDirectory.includes(`${segment}\\`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSessionDirectory(options: {
|
||||||
|
parentDirectory: string | null | undefined
|
||||||
|
fallbackDirectory: string
|
||||||
|
platform?: NodeJS.Platform
|
||||||
|
currentWorkingDirectory?: string
|
||||||
|
}): string {
|
||||||
|
const {
|
||||||
|
parentDirectory,
|
||||||
|
fallbackDirectory,
|
||||||
|
platform = process.platform,
|
||||||
|
currentWorkingDirectory = process.cwd(),
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const sessionDirectory = parentDirectory ?? fallbackDirectory
|
||||||
|
if (platform !== "win32") {
|
||||||
|
return sessionDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWindowsAppDataDirectory(sessionDirectory)) {
|
||||||
|
return sessionDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWindowsAppDataDirectory(currentWorkingDirectory)) {
|
||||||
|
return sessionDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentWorkingDirectory
|
||||||
|
}
|
||||||
@ -29,11 +29,17 @@ export interface RunOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||||
|
// ast-grep CLI silently ignores --update-all when --json is present.
|
||||||
|
// When both rewrite and updateAll are requested, we must run two separate
|
||||||
|
// invocations: one with --json=compact to collect match results, and
|
||||||
|
// another with --update-all to perform the actual file writes.
|
||||||
|
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
|
||||||
|
|
||||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||||
|
|
||||||
if (options.rewrite) {
|
if (options.rewrite) {
|
||||||
args.push("-r", options.rewrite)
|
args.push("-r", options.rewrite)
|
||||||
if (options.updateAll) {
|
if (options.updateAll && !shouldSeparateWritePass) {
|
||||||
args.push("--update-all")
|
args.push("--update-all")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,5 +150,28 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
return { matches: [], totalMatches: 0, truncated: false }
|
return { matches: [], totalMatches: 0, truncated: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return createSgResultFromStdout(stdout)
|
const jsonResult = createSgResultFromStdout(stdout)
|
||||||
|
|
||||||
|
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
|
||||||
|
const writeArgs = args.filter(a => a !== "--json=compact")
|
||||||
|
writeArgs.push("--update-all")
|
||||||
|
|
||||||
|
const writeProc = spawn([cliPath, ...writeArgs], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const writeOutput = await collectProcessOutputWithTimeout(writeProc, timeout)
|
||||||
|
if (writeOutput.exitCode !== 0) {
|
||||||
|
const errorDetail = writeOutput.stderr.trim() || `ast-grep exited with code ${writeOutput.exitCode}`
|
||||||
|
return { ...jsonResult, error: `Replace failed: ${errorDetail}` }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
return { ...jsonResult, error: `Replace failed: ${errorMessage}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResult
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,44 +4,88 @@ import { resolveOrCreateSessionId } from "./subagent-session-creator"
|
|||||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||||
|
|
||||||
describe("call-omo-agent resolveOrCreateSessionId", () => {
|
describe("call-omo-agent resolveOrCreateSessionId", () => {
|
||||||
test("tracks newly created child session as subagent session", async () => {
|
const originalPlatform = process.platform
|
||||||
// given
|
|
||||||
_resetForTesting()
|
function buildInput(options: {
|
||||||
|
parentDirectory?: string
|
||||||
|
contextDirectory: string
|
||||||
|
}): {
|
||||||
|
ctx: Parameters<typeof resolveOrCreateSessionId>[0]
|
||||||
|
args: Parameters<typeof resolveOrCreateSessionId>[1]
|
||||||
|
toolContext: Parameters<typeof resolveOrCreateSessionId>[2]
|
||||||
|
createCalls: Array<{ query?: { directory?: string } }>
|
||||||
|
} {
|
||||||
|
const createCalls: Array<{ query?: { directory?: string } }> = []
|
||||||
|
const { parentDirectory, contextDirectory } = options
|
||||||
|
const parentSessionData = parentDirectory ? { data: { directory: parentDirectory } } : { data: {} }
|
||||||
|
|
||||||
const createCalls: Array<unknown> = []
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
directory: "/project",
|
directory: contextDirectory,
|
||||||
client: {
|
client: {
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/parent" } }),
|
get: async () => parentSessionData,
|
||||||
create: async (args: unknown) => {
|
create: async (createInput: unknown) => {
|
||||||
createCalls.push(args)
|
const payload = createInput as { query?: { directory?: string } }
|
||||||
|
createCalls.push(payload)
|
||||||
return { data: { id: "ses_child_sync" } }
|
return { data: { id: "ses_child_sync" } }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
} as unknown as Parameters<typeof resolveOrCreateSessionId>[0]
|
||||||
|
|
||||||
const args = {
|
const args = {
|
||||||
description: "sync test",
|
description: "sync test",
|
||||||
prompt: "hello",
|
prompt: "hello",
|
||||||
subagent_type: "explore",
|
subagent_type: "explore",
|
||||||
run_in_background: false,
|
run_in_background: false,
|
||||||
}
|
} satisfies Parameters<typeof resolveOrCreateSessionId>[1]
|
||||||
|
|
||||||
const toolContext = {
|
const toolContext = {
|
||||||
sessionID: "ses_parent",
|
sessionID: "ses_parent",
|
||||||
messageID: "msg_parent",
|
messageID: "msg_parent",
|
||||||
agent: "sisyphus",
|
agent: "sisyphus",
|
||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
}
|
} satisfies Parameters<typeof resolveOrCreateSessionId>[2]
|
||||||
|
|
||||||
// when
|
return { ctx, args, toolContext, createCalls }
|
||||||
const result = await resolveOrCreateSessionId(ctx as any, args as any, toolContext as any)
|
}
|
||||||
|
|
||||||
// then
|
test("tracks newly created child session as subagent session", async () => {
|
||||||
|
//#given
|
||||||
|
_resetForTesting()
|
||||||
|
|
||||||
|
const { ctx, args, toolContext, createCalls } = buildInput({
|
||||||
|
parentDirectory: "/parent",
|
||||||
|
contextDirectory: "/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveOrCreateSessionId(ctx, args, toolContext)
|
||||||
|
|
||||||
|
//#then
|
||||||
expect(result).toEqual({ ok: true, sessionID: "ses_child_sync" })
|
expect(result).toEqual({ ok: true, sessionID: "ses_child_sync" })
|
||||||
expect(createCalls).toHaveLength(1)
|
expect(createCalls).toHaveLength(1)
|
||||||
expect(subagentSessions.has("ses_child_sync")).toBe(true)
|
expect(subagentSessions.has("ses_child_sync")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses current working directory on Windows when parent directory is under AppData", async () => {
|
||||||
|
//#given
|
||||||
|
_resetForTesting()
|
||||||
|
Object.defineProperty(process, "platform", { value: "win32" })
|
||||||
|
try {
|
||||||
|
const { ctx, args, toolContext, createCalls } = buildInput({
|
||||||
|
parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop",
|
||||||
|
contextDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await resolveOrCreateSessionId(ctx, args, toolContext)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(createCalls).toHaveLength(1)
|
||||||
|
expect(createCalls[0]?.query?.directory).toBe(process.cwd())
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import { resolveSessionDirectory } from "../../shared"
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||||
import type { CallOmoAgentArgs } from "./types"
|
import type { CallOmoAgentArgs } from "./types"
|
||||||
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
||||||
@ -27,11 +28,14 @@ export async function resolveOrCreateSessionId(
|
|||||||
log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
|
log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
|
||||||
const parentSession = await ctx.client.session
|
const parentSession = await ctx.client.session
|
||||||
.get({ path: { id: toolContext.sessionID } })
|
.get({ path: { id: toolContext.sessionID } })
|
||||||
.catch((err) => {
|
.catch((err: unknown) => {
|
||||||
log("[call_omo_agent] Failed to get parent session", { error: String(err) })
|
log("[call_omo_agent] Failed to get parent session", { error: String(err) })
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
|
const parentDirectory = resolveSessionDirectory({
|
||||||
|
parentDirectory: parentSession?.data?.directory,
|
||||||
|
fallbackDirectory: ctx.directory,
|
||||||
|
})
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
parentID: toolContext.sessionID,
|
parentID: toolContext.sessionID,
|
||||||
|
|||||||
102
src/tools/call-omo-agent/tools.test.ts
Normal file
102
src/tools/call-omo-agent/tools.test.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect, mock } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
import { createCallOmoAgent } from "./tools"
|
||||||
|
|
||||||
|
describe("createCallOmoAgent", () => {
|
||||||
|
const mockCtx = {
|
||||||
|
client: {},
|
||||||
|
directory: "/test",
|
||||||
|
} as unknown as PluginInput
|
||||||
|
|
||||||
|
const mockBackgroundManager = {
|
||||||
|
launch: mock(() => Promise.resolve({
|
||||||
|
id: "test-task-id",
|
||||||
|
sessionID: null,
|
||||||
|
description: "Test task",
|
||||||
|
agent: "test-agent",
|
||||||
|
status: "pending",
|
||||||
|
})),
|
||||||
|
} as unknown as BackgroundManager
|
||||||
|
|
||||||
|
test("should reject agent in disabled_agents list", async () => {
|
||||||
|
//#given
|
||||||
|
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"])
|
||||||
|
const executeFunc = toolDef.execute as Function
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await executeFunc(
|
||||||
|
{
|
||||||
|
description: "Test",
|
||||||
|
prompt: "Test prompt",
|
||||||
|
subagent_type: "explore",
|
||||||
|
run_in_background: true,
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toContain("disabled via disabled_agents")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
|
||||||
|
//#given
|
||||||
|
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"])
|
||||||
|
const executeFunc = toolDef.execute as Function
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await executeFunc(
|
||||||
|
{
|
||||||
|
description: "Test",
|
||||||
|
prompt: "Test prompt",
|
||||||
|
subagent_type: "explore",
|
||||||
|
run_in_background: true,
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toContain("disabled via disabled_agents")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should allow agent not in disabled_agents list", async () => {
|
||||||
|
//#given
|
||||||
|
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"])
|
||||||
|
const executeFunc = toolDef.execute as Function
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await executeFunc(
|
||||||
|
{
|
||||||
|
description: "Test",
|
||||||
|
prompt: "Test prompt",
|
||||||
|
subagent_type: "explore",
|
||||||
|
run_in_background: true,
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
// Should not contain disabled error - may fail for other reasons but disabled check should pass
|
||||||
|
expect(result).not.toContain("disabled via disabled_agents")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should allow all agents when disabled_agents is empty", async () => {
|
||||||
|
//#given
|
||||||
|
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||||
|
const executeFunc = toolDef.execute as Function
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await executeFunc(
|
||||||
|
{
|
||||||
|
description: "Test",
|
||||||
|
prompt: "Test prompt",
|
||||||
|
subagent_type: "explore",
|
||||||
|
run_in_background: true,
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).not.toContain("disabled via disabled_agents")
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,7 +8,8 @@ import { executeSync } from "./sync-executor"
|
|||||||
|
|
||||||
export function createCallOmoAgent(
|
export function createCallOmoAgent(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
backgroundManager: BackgroundManager
|
backgroundManager: BackgroundManager,
|
||||||
|
disabledAgents: string[] = []
|
||||||
): ToolDefinition {
|
): ToolDefinition {
|
||||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||||
(name) => `- ${name}: Specialized agent for ${name} tasks`
|
(name) => `- ${name}: Specialized agent for ${name} tasks`
|
||||||
@ -44,6 +45,11 @@ export function createCallOmoAgent(
|
|||||||
const normalizedAgent = args.subagent_type.toLowerCase() as AllowedAgentType
|
const normalizedAgent = args.subagent_type.toLowerCase() as AllowedAgentType
|
||||||
args = { ...args, subagent_type: normalizedAgent }
|
args = { ...args, subagent_type: normalizedAgent }
|
||||||
|
|
||||||
|
// Check if agent is disabled
|
||||||
|
if (disabledAgents.some((disabled) => disabled.toLowerCase() === normalizedAgent)) {
|
||||||
|
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.`
|
||||||
|
}
|
||||||
|
|
||||||
if (args.run_in_background) {
|
if (args.run_in_background) {
|
||||||
if (args.session_id) {
|
if (args.session_id) {
|
||||||
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
||||||
|
|||||||
82
src/tools/delegate-task/subagent-resolver.test.ts
Normal file
82
src/tools/delegate-task/subagent-resolver.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
declare const require: (name: string) => any
|
||||||
|
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
|
||||||
|
import { resolveSubagentExecution } from "./subagent-resolver"
|
||||||
|
import type { DelegateTaskArgs } from "./types"
|
||||||
|
import type { ExecutorContext } from "./executor-types"
|
||||||
|
import * as logger from "../../shared/logger"
|
||||||
|
|
||||||
|
function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs {
|
||||||
|
return {
|
||||||
|
description: "Run review",
|
||||||
|
prompt: "Review the current changes",
|
||||||
|
run_in_background: false,
|
||||||
|
load_skills: [],
|
||||||
|
subagent_type: "oracle",
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecutorContext(agentsFn: () => Promise<unknown>): ExecutorContext {
|
||||||
|
const client = {
|
||||||
|
app: {
|
||||||
|
agents: agentsFn,
|
||||||
|
},
|
||||||
|
} as ExecutorContext["client"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
manager: {} as ExecutorContext["manager"],
|
||||||
|
directory: "/tmp/test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveSubagentExecution", () => {
|
||||||
|
let logSpy: ReturnType<typeof spyOn> | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
logSpy = spyOn(logger, "log").mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy?.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns delegation error when agent discovery fails instead of silently proceeding", async () => {
|
||||||
|
//#given
|
||||||
|
const resolverError = new Error("agents API unavailable")
|
||||||
|
const args = createBaseArgs()
|
||||||
|
const executorCtx = createExecutorContext(async () => {
|
||||||
|
throw resolverError
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.agentToUse).toBe("")
|
||||||
|
expect(result.categoryModel).toBeUndefined()
|
||||||
|
expect(result.error).toBe("Failed to delegate to agent \"oracle\": agents API unavailable")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("logs failure details when subagent resolution throws", async () => {
|
||||||
|
//#given
|
||||||
|
const args = createBaseArgs({ subagent_type: "review" })
|
||||||
|
const executorCtx = createExecutorContext(async () => {
|
||||||
|
throw new Error("network timeout")
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const callArgs = logSpy?.mock.calls[0]
|
||||||
|
expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution")
|
||||||
|
expect(callArgs?.[1]).toEqual({
|
||||||
|
requestedAgent: "review",
|
||||||
|
parentAgent: "sisyphus",
|
||||||
|
error: "network timeout",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -6,6 +6,7 @@ import { parseModelString } from "./model-string-parser"
|
|||||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||||
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
|
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
import { getAvailableModelsForDelegateTask } from "./available-models"
|
import { getAvailableModelsForDelegateTask } from "./available-models"
|
||||||
import { resolveModelForDelegateTask } from "./model-selection"
|
import { resolveModelForDelegateTask } from "./model-selection"
|
||||||
|
|
||||||
@ -119,8 +120,19 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
if (!categoryModel && matchedAgent.model) {
|
if (!categoryModel && matchedAgent.model) {
|
||||||
categoryModel = matchedAgent.model
|
categoryModel = matchedAgent.model
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Proceed anyway - session.prompt will fail with clearer error if agent doesn't exist
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
log("[delegate-task] Failed to resolve subagent execution", {
|
||||||
|
requestedAgent: agentToUse,
|
||||||
|
parentAgent,
|
||||||
|
error: errorMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentToUse: "",
|
||||||
|
categoryModel: undefined,
|
||||||
|
error: `Failed to delegate to agent "${agentToUse}": ${errorMessage}`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { agentToUse, categoryModel }
|
return { agentToUse, categoryModel }
|
||||||
|
|||||||
30
src/tools/hashline-edit/constants.ts
Normal file
30
src/tools/hashline-edit/constants.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export const HASH_DICT = [
|
||||||
|
"00", "01", "02", "03", "04", "05", "06", "07", "08", "09",
|
||||||
|
"0a", "0b", "0c", "0d", "0e", "0f", "10", "11", "12", "13",
|
||||||
|
"14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d",
|
||||||
|
"1e", "1f", "20", "21", "22", "23", "24", "25", "26", "27",
|
||||||
|
"28", "29", "2a", "2b", "2c", "2d", "2e", "2f", "30", "31",
|
||||||
|
"32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b",
|
||||||
|
"3c", "3d", "3e", "3f", "40", "41", "42", "43", "44", "45",
|
||||||
|
"46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f",
|
||||||
|
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
|
||||||
|
"5a", "5b", "5c", "5d", "5e", "5f", "60", "61", "62", "63",
|
||||||
|
"64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d",
|
||||||
|
"6e", "6f", "70", "71", "72", "73", "74", "75", "76", "77",
|
||||||
|
"78", "79", "7a", "7b", "7c", "7d", "7e", "7f", "80", "81",
|
||||||
|
"82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b",
|
||||||
|
"8c", "8d", "8e", "8f", "90", "91", "92", "93", "94", "95",
|
||||||
|
"96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f",
|
||||||
|
"a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9",
|
||||||
|
"aa", "ab", "ac", "ad", "ae", "af", "b0", "b1", "b2", "b3",
|
||||||
|
"b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd",
|
||||||
|
"be", "bf", "c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7",
|
||||||
|
"c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf", "d0", "d1",
|
||||||
|
"d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db",
|
||||||
|
"dc", "dd", "de", "df", "e0", "e1", "e2", "e3", "e4", "e5",
|
||||||
|
"e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef",
|
||||||
|
"f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9",
|
||||||
|
"fa", "fb", "fc", "fd", "fe", "ff",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const HASHLINE_PATTERN = /^(\d+):([0-9a-f]{2})\|(.*)$/
|
||||||
321
src/tools/hashline-edit/edit-operations.test.ts
Normal file
321
src/tools/hashline-edit/edit-operations.test.ts
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import {
|
||||||
|
applyHashlineEdits,
|
||||||
|
applyInsertAfter,
|
||||||
|
applyReplace,
|
||||||
|
applyReplaceLines,
|
||||||
|
applySetLine,
|
||||||
|
} from "./edit-operations"
|
||||||
|
import type { HashlineEdit, InsertAfter, Replace, ReplaceLines, SetLine } from "./types"
|
||||||
|
|
||||||
|
describe("applySetLine", () => {
|
||||||
|
it("replaces a single line at the specified anchor", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const anchor = "2:b2" // line 2 hash
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applySetLine(lines, anchor, "new line 2")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "new line 2", "line 3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles newline escapes in replacement text", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const anchor = "2:b2"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applySetLine(lines, anchor, "new\\nline")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "new\nline", "line 3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on hash mismatch", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const anchor = "2:ff" // wrong hash
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applySetLine(lines, anchor, "new")).toThrow("Hash mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on out of bounds line", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2"]
|
||||||
|
const anchor = "5:00"
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applySetLine(lines, anchor, "new")).toThrow("out of bounds")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyReplaceLines", () => {
|
||||||
|
it("replaces a range of lines", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
|
||||||
|
const startAnchor = "2:b2"
|
||||||
|
const endAnchor = "4:5f"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyReplaceLines(lines, startAnchor, endAnchor, "replacement")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "replacement", "line 5"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles newline escapes in replacement text", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const startAnchor = "2:b2"
|
||||||
|
const endAnchor = "2:b2"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyReplaceLines(lines, startAnchor, endAnchor, "a\\nb")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "a", "b", "line 3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on start hash mismatch", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const startAnchor = "2:ff"
|
||||||
|
const endAnchor = "3:83"
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
|
||||||
|
"Hash mismatch"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on end hash mismatch", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const startAnchor = "2:b2"
|
||||||
|
const endAnchor = "3:ff"
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
|
||||||
|
"Hash mismatch"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when start > end", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const startAnchor = "3:83"
|
||||||
|
const endAnchor = "2:b2"
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
|
||||||
|
"start line 3 cannot be greater than end line 2"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyInsertAfter", () => {
|
||||||
|
it("inserts text after the specified line", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const anchor = "2:b2"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyInsertAfter(lines, anchor, "inserted")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles newline escapes to insert multiple lines", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const anchor = "2:b2"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyInsertAfter(lines, anchor, "a\\nb\\nc")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "line 2", "a", "b", "c", "line 3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("inserts at end when anchor is last line", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2"]
|
||||||
|
const anchor = "2:b2"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyInsertAfter(lines, anchor, "inserted")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["line 1", "line 2", "inserted"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on hash mismatch", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["line 1", "line 2"]
|
||||||
|
const anchor = "2:ff"
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applyInsertAfter(lines, anchor, "new")).toThrow("Hash mismatch")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyReplace", () => {
|
||||||
|
it("replaces exact text match", () => {
|
||||||
|
//#given
|
||||||
|
const content = "hello world foo bar"
|
||||||
|
const oldText = "world"
|
||||||
|
const newText = "universe"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyReplace(content, oldText, newText)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("hello universe foo bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("replaces all occurrences", () => {
|
||||||
|
//#given
|
||||||
|
const content = "foo bar foo baz foo"
|
||||||
|
const oldText = "foo"
|
||||||
|
const newText = "qux"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyReplace(content, oldText, newText)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("qux bar qux baz qux")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles newline escapes in newText", () => {
|
||||||
|
//#given
|
||||||
|
const content = "hello world"
|
||||||
|
const oldText = "world"
|
||||||
|
const newText = "new\\nline"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyReplace(content, oldText, newText)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("hello new\nline")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when oldText not found", () => {
|
||||||
|
//#given
|
||||||
|
const content = "hello world"
|
||||||
|
const oldText = "notfound"
|
||||||
|
const newText = "replacement"
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applyReplace(content, oldText, newText)).toThrow("Text not found")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyHashlineEdits", () => {
|
||||||
|
it("applies single set_line edit", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2\nline 3"
|
||||||
|
const edits: SetLine[] = [{ type: "set_line", line: "2:b2", text: "new line 2" }]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("line 1\nnew line 2\nline 3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies multiple edits bottom-up (descending line order)", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
|
||||||
|
const edits: SetLine[] = [
|
||||||
|
{ type: "set_line", line: "2:b2", text: "new 2" },
|
||||||
|
{ type: "set_line", line: "4:5f", text: "new 4" },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("line 1\nnew 2\nline 3\nnew 4\nline 5")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies mixed edit types", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2\nline 3"
|
||||||
|
const edits: HashlineEdit[] = [
|
||||||
|
{ type: "insert_after", line: "1:02", text: "inserted" },
|
||||||
|
{ type: "set_line", line: "3:83", text: "modified" },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies replace_lines edit", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2\nline 3\nline 4"
|
||||||
|
const edits: ReplaceLines[] = [
|
||||||
|
{ type: "replace_lines", start_line: "2:b2", end_line: "3:83", text: "replaced" },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("line 1\nreplaced\nline 4")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies replace fallback edit", () => {
|
||||||
|
//#given
|
||||||
|
const content = "hello world foo"
|
||||||
|
const edits: Replace[] = [{ type: "replace", old_text: "world", new_text: "universe" }]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("hello universe foo")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles empty edits array", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2"
|
||||||
|
const edits: HashlineEdit[] = []
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("line 1\nline 2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on hash mismatch with descriptive error", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2\nline 3"
|
||||||
|
const edits: SetLine[] = [{ type: "set_line", line: "2:ff", text: "new" }]
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => applyHashlineEdits(content, edits)).toThrow("Hash mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("correctly handles index shifting with multiple edits", () => {
|
||||||
|
//#given
|
||||||
|
const content = "a\nb\nc\nd\ne"
|
||||||
|
const edits: InsertAfter[] = [
|
||||||
|
{ type: "insert_after", line: "2:bf", text: "x" },
|
||||||
|
{ type: "insert_after", line: "4:90", text: "y" },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, edits)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("a\nb\nx\nc\nd\ny\ne")
|
||||||
|
})
|
||||||
|
})
|
||||||
123
src/tools/hashline-edit/edit-operations.ts
Normal file
123
src/tools/hashline-edit/edit-operations.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { parseLineRef, validateLineRef } from "./validation"
|
||||||
|
import type { HashlineEdit } from "./types"
|
||||||
|
|
||||||
|
function unescapeNewlines(text: string): string {
|
||||||
|
return text.replace(/\\n/g, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySetLine(lines: string[], anchor: string, newText: string): string[] {
|
||||||
|
validateLineRef(lines, anchor)
|
||||||
|
const { line } = parseLineRef(anchor)
|
||||||
|
const result = [...lines]
|
||||||
|
result[line - 1] = unescapeNewlines(newText)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReplaceLines(
|
||||||
|
lines: string[],
|
||||||
|
startAnchor: string,
|
||||||
|
endAnchor: string,
|
||||||
|
newText: string
|
||||||
|
): string[] {
|
||||||
|
validateLineRef(lines, startAnchor)
|
||||||
|
validateLineRef(lines, endAnchor)
|
||||||
|
|
||||||
|
const { line: startLine } = parseLineRef(startAnchor)
|
||||||
|
const { line: endLine } = parseLineRef(endAnchor)
|
||||||
|
|
||||||
|
if (startLine > endLine) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...lines]
|
||||||
|
const newLines = unescapeNewlines(newText).split("\n")
|
||||||
|
result.splice(startLine - 1, endLine - startLine + 1, ...newLines)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInsertAfter(lines: string[], anchor: string, text: string): string[] {
|
||||||
|
validateLineRef(lines, anchor)
|
||||||
|
const { line } = parseLineRef(anchor)
|
||||||
|
const result = [...lines]
|
||||||
|
const newLines = unescapeNewlines(text).split("\n")
|
||||||
|
result.splice(line, 0, ...newLines)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReplace(content: string, oldText: string, newText: string): string {
|
||||||
|
if (!content.includes(oldText)) {
|
||||||
|
throw new Error(`Text not found: "${oldText}"`)
|
||||||
|
}
|
||||||
|
return content.replaceAll(oldText, unescapeNewlines(newText))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditLineNumber(edit: HashlineEdit): number {
|
||||||
|
switch (edit.type) {
|
||||||
|
case "set_line":
|
||||||
|
return parseLineRef(edit.line).line
|
||||||
|
case "replace_lines":
|
||||||
|
return parseLineRef(edit.end_line).line
|
||||||
|
case "insert_after":
|
||||||
|
return parseLineRef(edit.line).line
|
||||||
|
case "replace":
|
||||||
|
return Number.NEGATIVE_INFINITY
|
||||||
|
default:
|
||||||
|
return Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||||
|
if (edits.length === 0) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
|
||||||
|
|
||||||
|
let result = content
|
||||||
|
let lines = result.split("\n")
|
||||||
|
|
||||||
|
for (const edit of sortedEdits) {
|
||||||
|
switch (edit.type) {
|
||||||
|
case "set_line": {
|
||||||
|
validateLineRef(lines, edit.line)
|
||||||
|
const { line } = parseLineRef(edit.line)
|
||||||
|
lines[line - 1] = unescapeNewlines(edit.text)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "replace_lines": {
|
||||||
|
validateLineRef(lines, edit.start_line)
|
||||||
|
validateLineRef(lines, edit.end_line)
|
||||||
|
const { line: startLine } = parseLineRef(edit.start_line)
|
||||||
|
const { line: endLine } = parseLineRef(edit.end_line)
|
||||||
|
if (startLine > endLine) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const newLines = unescapeNewlines(edit.text).split("\n")
|
||||||
|
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "insert_after": {
|
||||||
|
validateLineRef(lines, edit.line)
|
||||||
|
const { line } = parseLineRef(edit.line)
|
||||||
|
const newLines = unescapeNewlines(edit.text).split("\n")
|
||||||
|
lines.splice(line, 0, ...newLines)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "replace": {
|
||||||
|
result = lines.join("\n")
|
||||||
|
if (!result.includes(edit.old_text)) {
|
||||||
|
throw new Error(`Text not found: "${edit.old_text}"`)
|
||||||
|
}
|
||||||
|
result = result.replaceAll(edit.old_text, unescapeNewlines(edit.new_text))
|
||||||
|
lines = result.split("\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
123
src/tools/hashline-edit/hash-computation.test.ts
Normal file
123
src/tools/hashline-edit/hash-computation.test.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation"
|
||||||
|
|
||||||
|
describe("computeLineHash", () => {
|
||||||
|
it("returns consistent 2-char hex for same input", () => {
|
||||||
|
//#given
|
||||||
|
const lineNumber = 1
|
||||||
|
const content = "function hello() {"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const hash1 = computeLineHash(lineNumber, content)
|
||||||
|
const hash2 = computeLineHash(lineNumber, content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(hash1).toBe(hash2)
|
||||||
|
expect(hash1).toMatch(/^[0-9a-f]{2}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips whitespace before hashing", () => {
|
||||||
|
//#given
|
||||||
|
const lineNumber = 1
|
||||||
|
const content1 = "function hello() {"
|
||||||
|
const content2 = " function hello() { "
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const hash1 = computeLineHash(lineNumber, content1)
|
||||||
|
const hash2 = computeLineHash(lineNumber, content2)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(hash1).toBe(hash2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles empty lines", () => {
|
||||||
|
//#given
|
||||||
|
const lineNumber = 1
|
||||||
|
const content = ""
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const hash = computeLineHash(lineNumber, content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(hash).toMatch(/^[0-9a-f]{2}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns different hashes for different content", () => {
|
||||||
|
//#given
|
||||||
|
const lineNumber = 1
|
||||||
|
const content1 = "function hello() {"
|
||||||
|
const content2 = "function world() {"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const hash1 = computeLineHash(lineNumber, content1)
|
||||||
|
const hash2 = computeLineHash(lineNumber, content2)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(hash1).not.toBe(hash2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("formatHashLine", () => {
|
||||||
|
it("formats line with hash prefix", () => {
|
||||||
|
//#given
|
||||||
|
const lineNumber = 42
|
||||||
|
const content = "function hello() {"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = formatHashLine(lineNumber, content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toMatch(/^42:[0-9a-f]{2}\|function hello\(\) \{$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves content after hash prefix", () => {
|
||||||
|
//#given
|
||||||
|
const lineNumber = 1
|
||||||
|
const content = "const x = 42"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = formatHashLine(lineNumber, content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toContain("|const x = 42")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("formatHashLines", () => {
|
||||||
|
it("formats all lines with hash prefixes", () => {
|
||||||
|
//#given
|
||||||
|
const content = "function hello() {\n return 42\n}"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = formatHashLines(content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const lines = result.split("\n")
|
||||||
|
expect(lines).toHaveLength(3)
|
||||||
|
expect(lines[0]).toMatch(/^1:[0-9a-f]{2}\|/)
|
||||||
|
expect(lines[1]).toMatch(/^2:[0-9a-f]{2}\|/)
|
||||||
|
expect(lines[2]).toMatch(/^3:[0-9a-f]{2}\|/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles empty file", () => {
|
||||||
|
//#given
|
||||||
|
const content = ""
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = formatHashLines(content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles single line", () => {
|
||||||
|
//#given
|
||||||
|
const content = "const x = 42"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = formatHashLines(content)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toMatch(/^1:[0-9a-f]{2}\|const x = 42$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
19
src/tools/hashline-edit/hash-computation.ts
Normal file
19
src/tools/hashline-edit/hash-computation.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { HASH_DICT } from "./constants"
|
||||||
|
|
||||||
|
export function computeLineHash(lineNumber: number, content: string): string {
|
||||||
|
const stripped = content.replace(/\s+/g, "")
|
||||||
|
const hash = Bun.hash.xxHash32(stripped)
|
||||||
|
const index = hash % 256
|
||||||
|
return HASH_DICT[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHashLine(lineNumber: number, content: string): string {
|
||||||
|
const hash = computeLineHash(lineNumber, content)
|
||||||
|
return `${lineNumber}:${hash}|${content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHashLines(content: string): string {
|
||||||
|
if (!content) return ""
|
||||||
|
const lines = content.split("\n")
|
||||||
|
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
|
||||||
|
}
|
||||||
13
src/tools/hashline-edit/index.ts
Normal file
13
src/tools/hashline-edit/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation"
|
||||||
|
export { parseLineRef, validateLineRef } from "./validation"
|
||||||
|
export type { LineRef } from "./validation"
|
||||||
|
export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types"
|
||||||
|
export { HASH_DICT, HASHLINE_PATTERN } from "./constants"
|
||||||
|
export {
|
||||||
|
applyHashlineEdits,
|
||||||
|
applyInsertAfter,
|
||||||
|
applyReplace,
|
||||||
|
applyReplaceLines,
|
||||||
|
applySetLine,
|
||||||
|
} from "./edit-operations"
|
||||||
|
export { createHashlineEditTool } from "./tools"
|
||||||
239
src/tools/hashline-edit/tools.test.ts
Normal file
239
src/tools/hashline-edit/tools.test.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||||
|
import { createHashlineEditTool } from "./tools"
|
||||||
|
import * as fs from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import * as os from "node:os"
|
||||||
|
import { computeLineHash } from "./hash-computation"
|
||||||
|
|
||||||
|
describe("createHashlineEditTool", () => {
|
||||||
|
let tempDir: string
|
||||||
|
let tool: ReturnType<typeof createHashlineEditTool>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-edit-test-"))
|
||||||
|
tool = createHashlineEditTool()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tool definition", () => {
|
||||||
|
it("has correct description", () => {
|
||||||
|
//#given tool is created
|
||||||
|
//#when accessing tool properties
|
||||||
|
//#then description explains LINE:HASH format
|
||||||
|
expect(tool.description).toContain("LINE:HASH")
|
||||||
|
expect(tool.description).toContain("set_line")
|
||||||
|
expect(tool.description).toContain("replace_lines")
|
||||||
|
expect(tool.description).toContain("insert_after")
|
||||||
|
expect(tool.description).toContain("replace")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("has path parameter", () => {
|
||||||
|
//#given tool is created
|
||||||
|
//#when checking parameters
|
||||||
|
//#then path parameter exists as required string
|
||||||
|
expect(tool.args.path).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("has edits parameter as array", () => {
|
||||||
|
//#given tool is created
|
||||||
|
//#when checking parameters
|
||||||
|
//#then edits parameter exists as array
|
||||||
|
expect(tool.args.edits).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("execute", () => {
|
||||||
|
it("returns error when file does not exist", async () => {
|
||||||
|
//#given non-existent file path
|
||||||
|
const nonExistentPath = path.join(tempDir, "non-existent.txt")
|
||||||
|
|
||||||
|
//#when executing tool
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: nonExistentPath,
|
||||||
|
edits: [{ type: "set_line", line: "1:00", text: "new content" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then error is returned
|
||||||
|
expect(result).toContain("Error")
|
||||||
|
expect(result).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies set_line edit and returns diff", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||||
|
const line2Hash = computeLineHash(2, "line2")
|
||||||
|
|
||||||
|
//#when executing set_line edit
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified line2" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then file is modified and diff is returned
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
expect(content).toBe("line1\nmodified line2\nline3")
|
||||||
|
expect(result).toContain("modified line2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies insert_after edit", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1\nline2")
|
||||||
|
const line1Hash = computeLineHash(1, "line1")
|
||||||
|
|
||||||
|
//#when executing insert_after edit
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [{ type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then line is inserted after specified line
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
expect(content).toBe("line1\ninserted\nline2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies replace_lines edit", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1\nline2\nline3\nline4")
|
||||||
|
const line2Hash = computeLineHash(2, "line2")
|
||||||
|
const line3Hash = computeLineHash(3, "line3")
|
||||||
|
|
||||||
|
//#when executing replace_lines edit
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
type: "replace_lines",
|
||||||
|
start_line: `2:${line2Hash}`,
|
||||||
|
end_line: `3:${line3Hash}`,
|
||||||
|
text: "replaced",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then lines are replaced
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
expect(content).toBe("line1\nreplaced\nline4")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies replace edit", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "hello world\nfoo bar")
|
||||||
|
|
||||||
|
//#when executing replace edit
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [{ type: "replace", old_text: "world", new_text: "universe" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then text is replaced
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
expect(content).toBe("hello universe\nfoo bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies multiple edits in bottom-up order", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||||
|
const line1Hash = computeLineHash(1, "line1")
|
||||||
|
const line3Hash = computeLineHash(3, "line3")
|
||||||
|
|
||||||
|
//#when executing multiple edits
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [
|
||||||
|
{ type: "set_line", line: `1:${line1Hash}`, text: "new1" },
|
||||||
|
{ type: "set_line", line: `3:${line3Hash}`, text: "new3" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then both edits are applied
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
expect(content).toBe("new1\nline2\nnew3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns error on hash mismatch", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1\nline2")
|
||||||
|
|
||||||
|
//#when executing with wrong hash (valid format but wrong value)
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [{ type: "set_line", line: "1:ff", text: "new" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then hash mismatch error is returned
|
||||||
|
expect(result).toContain("Error")
|
||||||
|
expect(result).toContain("hash")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles escaped newlines in text", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1\nline2")
|
||||||
|
const line1Hash = computeLineHash(1, "line1")
|
||||||
|
|
||||||
|
//#when executing with escaped newline
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then newline is unescaped
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
expect(content).toBe("new\nline\nline2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns success result with diff summary", async () => {
|
||||||
|
//#given file with content
|
||||||
|
const filePath = path.join(tempDir, "test.txt")
|
||||||
|
fs.writeFileSync(filePath, "old content")
|
||||||
|
const line1Hash = computeLineHash(1, "old content")
|
||||||
|
|
||||||
|
//#when executing edit
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new content" }],
|
||||||
|
},
|
||||||
|
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then result contains success indicator and diff
|
||||||
|
expect(result).toContain("Successfully")
|
||||||
|
expect(result).toContain("old content")
|
||||||
|
expect(result).toContain("new content")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
137
src/tools/hashline-edit/tools.ts
Normal file
137
src/tools/hashline-edit/tools.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
|
import type { HashlineEdit } from "./types"
|
||||||
|
import { applyHashlineEdits } from "./edit-operations"
|
||||||
|
import { computeLineHash } from "./hash-computation"
|
||||||
|
|
||||||
|
interface HashlineEditArgs {
|
||||||
|
path: string
|
||||||
|
edits: HashlineEdit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDiff(oldContent: string, newContent: string, filePath: string): string {
|
||||||
|
const oldLines = oldContent.split("\n")
|
||||||
|
const newLines = newContent.split("\n")
|
||||||
|
|
||||||
|
let diff = `--- ${filePath}\n+++ ${filePath}\n`
|
||||||
|
|
||||||
|
const maxLines = Math.max(oldLines.length, newLines.length)
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const oldLine = oldLines[i] ?? ""
|
||||||
|
const newLine = newLines[i] ?? ""
|
||||||
|
const lineNum = i + 1
|
||||||
|
const hash = computeLineHash(lineNum, newLine)
|
||||||
|
|
||||||
|
if (i >= oldLines.length) {
|
||||||
|
diff += `+ ${lineNum}:${hash}|${newLine}\n`
|
||||||
|
} else if (i >= newLines.length) {
|
||||||
|
diff += `- ${lineNum}: |${oldLine}\n`
|
||||||
|
} else if (oldLine !== newLine) {
|
||||||
|
diff += `- ${lineNum}: |${oldLine}\n`
|
||||||
|
diff += `+ ${lineNum}:${hash}|${newLine}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHashlineEditTool(): ToolDefinition {
|
||||||
|
return tool({
|
||||||
|
description: `Edit files using LINE:HASH format for precise, safe modifications.
|
||||||
|
|
||||||
|
LINE:HASH FORMAT:
|
||||||
|
Each line reference must be in "LINE:HASH" format where:
|
||||||
|
- LINE: 1-based line number
|
||||||
|
- HASH: First 2 characters of xxHash32 hash of line content (computed with computeLineHash)
|
||||||
|
- Example: "5:a3|const x = 1" means line 5 with hash "a3"
|
||||||
|
|
||||||
|
GETTING HASHES:
|
||||||
|
Use the read tool - it returns lines in "LINE:HASH|content" format.
|
||||||
|
|
||||||
|
FOUR OPERATION TYPES:
|
||||||
|
|
||||||
|
1. set_line: Replace a single line
|
||||||
|
{ "type": "set_line", "line": "5:a3", "text": "const y = 2" }
|
||||||
|
|
||||||
|
2. replace_lines: Replace a range of lines
|
||||||
|
{ "type": "replace_lines", "start_line": "5:a3", "end_line": "7:b2", "text": "new\ncontent" }
|
||||||
|
|
||||||
|
3. insert_after: Insert lines after a specific line
|
||||||
|
{ "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" }
|
||||||
|
|
||||||
|
4. replace: Simple text replacement (no hash validation)
|
||||||
|
{ "type": "replace", "old_text": "foo", "new_text": "bar" }
|
||||||
|
|
||||||
|
HASH MISMATCH HANDLING:
|
||||||
|
If the hash doesn't match the current content, the edit fails with a hash mismatch error. This prevents editing stale content.
|
||||||
|
|
||||||
|
BOTTOM-UP APPLICATION:
|
||||||
|
Edits are applied from bottom to top (highest line numbers first) to preserve line number references.
|
||||||
|
|
||||||
|
ESCAPING:
|
||||||
|
Use \\n in text to represent literal newlines.`,
|
||||||
|
args: {
|
||||||
|
path: tool.schema.string().describe("Absolute path to the file to edit"),
|
||||||
|
edits: tool.schema
|
||||||
|
.array(
|
||||||
|
tool.schema.union([
|
||||||
|
tool.schema.object({
|
||||||
|
type: tool.schema.literal("set_line"),
|
||||||
|
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
|
||||||
|
text: tool.schema.string().describe("New content for the line"),
|
||||||
|
}),
|
||||||
|
tool.schema.object({
|
||||||
|
type: tool.schema.literal("replace_lines"),
|
||||||
|
start_line: tool.schema.string().describe("Start line in LINE:HASH format"),
|
||||||
|
end_line: tool.schema.string().describe("End line in LINE:HASH format"),
|
||||||
|
text: tool.schema.string().describe("New content to replace the range"),
|
||||||
|
}),
|
||||||
|
tool.schema.object({
|
||||||
|
type: tool.schema.literal("insert_after"),
|
||||||
|
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
|
||||||
|
text: tool.schema.string().describe("Content to insert after the line"),
|
||||||
|
}),
|
||||||
|
tool.schema.object({
|
||||||
|
type: tool.schema.literal("replace"),
|
||||||
|
old_text: tool.schema.string().describe("Text to find"),
|
||||||
|
new_text: tool.schema.string().describe("Replacement text"),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.describe("Array of edit operations to apply"),
|
||||||
|
},
|
||||||
|
execute: async (args: HashlineEditArgs) => {
|
||||||
|
try {
|
||||||
|
const { path: filePath, edits } = args
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return "Error: path parameter is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!edits || !Array.isArray(edits) || edits.length === 0) {
|
||||||
|
return "Error: edits parameter must be a non-empty array"
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
const exists = await file.exists()
|
||||||
|
if (!exists) {
|
||||||
|
return `Error: File not found: ${filePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldContent = await file.text()
|
||||||
|
const newContent = applyHashlineEdits(oldContent, edits)
|
||||||
|
|
||||||
|
await Bun.write(filePath, newContent)
|
||||||
|
|
||||||
|
const diff = generateDiff(oldContent, newContent, filePath)
|
||||||
|
|
||||||
|
return `Successfully applied ${edits.length} edit(s) to ${filePath}\n\n${diff}`
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (message.includes("hash")) {
|
||||||
|
return `Error: Hash mismatch - ${message}`
|
||||||
|
}
|
||||||
|
return `Error: ${message}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
26
src/tools/hashline-edit/types.ts
Normal file
26
src/tools/hashline-edit/types.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface SetLine {
|
||||||
|
type: "set_line"
|
||||||
|
line: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceLines {
|
||||||
|
type: "replace_lines"
|
||||||
|
start_line: string
|
||||||
|
end_line: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertAfter {
|
||||||
|
type: "insert_after"
|
||||||
|
line: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Replace {
|
||||||
|
type: "replace"
|
||||||
|
old_text: string
|
||||||
|
new_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | Replace
|
||||||
105
src/tools/hashline-edit/validation.test.ts
Normal file
105
src/tools/hashline-edit/validation.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import { parseLineRef, validateLineRef } from "./validation"
|
||||||
|
|
||||||
|
describe("parseLineRef", () => {
|
||||||
|
it("parses valid line reference", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "42:a3"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseLineRef(ref)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual({ line: 42, hash: "a3" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses line reference with different hash", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "1:ff"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseLineRef(ref)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual({ line: 1, hash: "ff" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on invalid format - no colon", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "42a3"
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => parseLineRef(ref)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on invalid format - non-numeric line", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "abc:a3"
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => parseLineRef(ref)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on invalid format - invalid hash", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "42:xyz"
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => parseLineRef(ref)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on empty string", () => {
|
||||||
|
//#given
|
||||||
|
const ref = ""
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => parseLineRef(ref)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateLineRef", () => {
|
||||||
|
it("validates matching hash", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["function hello() {", " return 42", "}"]
|
||||||
|
const ref = "1:42"
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => validateLineRef(lines, ref)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on hash mismatch", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["function hello() {", " return 42", "}"]
|
||||||
|
const ref = "1:00" // Wrong hash
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => validateLineRef(lines, ref)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on line out of bounds", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["function hello() {", " return 42", "}"]
|
||||||
|
const ref = "99:a3"
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => validateLineRef(lines, ref)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on invalid line number", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["function hello() {"]
|
||||||
|
const ref = "0:a3" // Line numbers start at 1
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => validateLineRef(lines, ref)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("error message includes current hash", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["function hello() {"]
|
||||||
|
const ref = "1:00"
|
||||||
|
|
||||||
|
//#when & #then
|
||||||
|
expect(() => validateLineRef(lines, ref)).toThrow(/current hash/)
|
||||||
|
})
|
||||||
|
})
|
||||||
39
src/tools/hashline-edit/validation.ts
Normal file
39
src/tools/hashline-edit/validation.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { computeLineHash } from "./hash-computation"
|
||||||
|
|
||||||
|
export interface LineRef {
|
||||||
|
line: number
|
||||||
|
hash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLineRef(ref: string): LineRef {
|
||||||
|
const match = ref.match(/^(\d+):([0-9a-f]{2})$/)
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid line reference format: "${ref}". Expected format: "LINE:HASH" (e.g., "42:a3")`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
line: Number.parseInt(match[1], 10),
|
||||||
|
hash: match[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLineRef(lines: string[], ref: string): void {
|
||||||
|
const { line, hash } = parseLineRef(ref)
|
||||||
|
|
||||||
|
if (line < 1 || line > lines.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = lines[line - 1]
|
||||||
|
const currentHash = computeLineHash(line, content)
|
||||||
|
|
||||||
|
if (currentHash !== hash) {
|
||||||
|
throw new Error(
|
||||||
|
`Hash mismatch at line ${line}. Expected hash: ${hash}, current hash: ${currentHash}. ` +
|
||||||
|
`Line content may have changed. Current content: "${content}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,7 @@ export {
|
|||||||
createTaskList,
|
createTaskList,
|
||||||
createTaskUpdateTool,
|
createTaskUpdateTool,
|
||||||
} from "./task"
|
} from "./task"
|
||||||
|
export { createHashlineEditTool } from "./hashline-edit"
|
||||||
|
|
||||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
|
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
|
||||||
const outputManager: BackgroundOutputManager = manager
|
const outputManager: BackgroundOutputManager = manager
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"
|
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
@ -38,6 +38,27 @@ mock.module("../../shared/opencode-storage-paths", () => ({
|
|||||||
SESSION_STORAGE: TEST_SESSION_STORAGE,
|
SESSION_STORAGE: TEST_SESSION_STORAGE,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("../../shared/opencode-message-dir", () => ({
|
||||||
|
getMessageDir: (sessionID: string) => {
|
||||||
|
if (!sessionID.startsWith("ses_")) return null
|
||||||
|
if (/[/\\]|\.\./.test(sessionID)) return null
|
||||||
|
if (!existsSync(TEST_MESSAGE_STORAGE)) return null
|
||||||
|
|
||||||
|
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(directPath)) {
|
||||||
|
return directPath
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of readdirSync(TEST_MESSAGE_STORAGE)) {
|
||||||
|
const nestedPath = join(TEST_MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(nestedPath)) {
|
||||||
|
return nestedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}))
|
||||||
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
|
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
|
||||||
await import("./storage")
|
await import("./storage")
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user