diff --git a/packages/plugin/src/examples/goal-loop.test.ts b/packages/plugin/src/examples/goal-loop.test.ts new file mode 100644 index 000000000000..f61ad4b8c4e9 --- /dev/null +++ b/packages/plugin/src/examples/goal-loop.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test" +import fs from "fs/promises" +import path from "path" +import os from "os" + +let tmpdir: string + +function makeTool(fn) { + return { execute: fn } +} + +mock.module("@opencode-ai/plugin", () => ({ + tool: makeTool, +})) + +const GoalLoopPlugin = (await import("./goal-loop.js")).default + +function createTestPlugin() { + const hooks = GoalLoopPlugin.server({ directory: tmpdir, project: null, user: null }) + return hooks.tool +} + +beforeEach(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "goal-loop-test-")) +}) + +afterEach(async () => { + await fs.rm(tmpdir, { recursive: true, force: true }) +}) + +describe("goal tool", () => { + test("set and clear round-trips", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set", condition: "say hello" }) + expect(result).toContain("say hello") + + const state = JSON.parse(await fs.readFile(path.join(tmpdir, ".opencode/harness/goal.json"), "utf-8")) + expect(state.active).toBe(true) + expect(state.condition).toBe("say hello") + expect(state.turns).toBe(0) + + const clear = await goal.execute({ action: "clear" }) + expect(clear).toBe("Goal cleared.") + }) + + test("status shows active goal", async () => { + const { goal } = createTestPlugin() + await goal.execute({ action: "set", condition: "check tests" }) + const status = await goal.execute({ action: "status" }) + expect(status).toContain("check tests") + expect(status).toContain("Turns: 0") + }) + + test("status when no goal", async () => { + const { goal } = createTestPlugin() + const status = await goal.execute({ action: "status" }) + expect(status).toBe("No active goal.") + }) + + test("set without condition fails", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set" }) + expect(result).toBe("Provide a condition.") + }) + + test("set with maxTurns", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set", condition: "fix bug", maxTurns: 10 }) + expect(result).toContain("max 10 turns") + + const state = JSON.parse(await fs.readFile(path.join(tmpdir, ".opencode/harness/goal.json"), "utf-8")) + expect(state.maxTurns).toBe(10) + + const status = await goal.execute({ action: "status" }) + expect(status).toContain("Max: 10") + }) + + test("set with oneTaskPerTurn", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set", condition: "write tests", oneTaskPerTurn: true }) + expect(result).toContain("one task per turn") + + const state = JSON.parse(await fs.readFile(path.join(tmpdir, ".opencode/harness/goal.json"), "utf-8")) + expect(state.oneTaskPerTurn).toBe(true) + + const status = await goal.execute({ action: "status" }) + expect(status).toContain("One task/turn") + }) +}) + +describe("loop tool", () => { + test("add and list round-trips", async () => { + const { loop } = createTestPlugin() + const result = await loop.execute({ action: "add", prompt: "check status", interval: "5m" }) + expect(result).toContain("check status") + expect(result).toContain("5m") + + const list = await loop.execute({ action: "list" }) + expect(list).toContain("check status") + }) + + test("remove loop", async () => { + const { loop } = createTestPlugin() + await loop.execute({ action: "add", prompt: "check status", interval: "5m" }) + const list = await loop.execute({ action: "list" }) + const id = list.match(/\[(\w+)\]/)?.[1] + expect(id).toBeTruthy() + + const removed = await loop.execute({ action: "remove", loopId: id }) + expect(removed).toContain(id) + + const afterRemove = await loop.execute({ action: "list" }) + expect(afterRemove).toBe("No active loops.") + }) + + test("list when no loops", async () => { + const { loop } = createTestPlugin() + const list = await loop.execute({ action: "list" }) + expect(list).toBe("No active loops.") + }) + + test("add without prompt fails", async () => { + const { loop } = createTestPlugin() + const result = await loop.execute({ action: "add" }) + expect(result).toBe("Provide a prompt.") + }) +}) diff --git a/packages/plugin/src/examples/goal-loop.ts b/packages/plugin/src/examples/goal-loop.ts new file mode 100644 index 000000000000..8e1e37496c80 --- /dev/null +++ b/packages/plugin/src/examples/goal-loop.ts @@ -0,0 +1,121 @@ +import { tool } from "@opencode-ai/plugin" +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" +import { join } from "path" + +const HARNESS_DIR = ".opencode/harness" + +function ensureDir(dir: string) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) +} + +function readJson(file: string, fallback: T): T { + try { + const p = join(HARNESS_DIR, file) + if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) + } catch {} + return fallback +} + +function writeJson(file: string, data: unknown) { + ensureDir(HARNESS_DIR) + writeFileSync(join(HARNESS_DIR, file), JSON.stringify(data, null, 2), "utf-8") +} + +export default { + id: "goal-loop", + server: async ({ directory }: { directory: string }) => { + return { + tool: { + goal: tool({ + description: + "Set, check, or clear an autonomous goal. goal({action:'set',condition:'...'}) to set. goal({action:'status'}) to check. goal({action:'clear'}) to clear.", + args: { + action: tool.schema.enum(["set", "status", "clear"]), + condition: tool.schema.string().optional(), + maxTurns: tool.schema.number().optional(), + oneTaskPerTurn: tool.schema.boolean().optional(), + }, + async execute(args: { + action: "set" | "status" | "clear" + condition?: string + maxTurns?: number + oneTaskPerTurn?: boolean + }) { + if (args.action === "clear") { + writeJson("goal.json", { active: false }) + return "Goal cleared." + } + if (args.action === "status") { + const g = readJson<{ + condition: string + active: boolean + turns: number + maxTurns?: number + oneTaskPerTurn?: boolean + startedAt: string + } | null>("goal.json", null) + if (!g || !g.active) return "No active goal." + const limit = g.maxTurns ? ` | Max: ${g.maxTurns}` : "" + const taskMode = g.oneTaskPerTurn ? " | One task/turn" : "" + return `Goal: "${g.condition}" | Turns: ${g.turns || 0}${limit}${taskMode} | Since: ${g.startedAt}` + } + if (!args.condition) return "Provide a condition." + const goal = { + condition: args.condition, + active: true, + turns: 0, + maxTurns: args.maxTurns || null, + oneTaskPerTurn: args.oneTaskPerTurn || false, + startedAt: new Date().toISOString(), + } + writeJson("goal.json", goal) + const extras: string[] = [] + if (goal.maxTurns) extras.push(`max ${goal.maxTurns} turns`) + if (goal.oneTaskPerTurn) extras.push("one task per turn") + const hint = extras.length ? ` (${extras.join(", ")})` : "" + return `Goal set: "${goal.condition}"${hint}. Work toward it. Call goal({action:'clear'}) when done.` + }, + }), + + loop: tool({ + description: + "Schedule a recurring prompt. loop({action:'add',prompt:'...',interval:'5m'}) to add. loop({action:'list'}) to list. loop({action:'remove',loopId:'...'}) to remove.", + args: { + action: tool.schema.enum(["add", "list", "remove"]), + prompt: tool.schema.string().optional(), + interval: tool.schema.string().optional(), + loopId: tool.schema.string().optional(), + }, + async execute(args: { + action: "add" | "list" | "remove" + prompt?: string + interval?: string + loopId?: string + }) { + if (args.action === "list") { + const loops = readJson>("loops.json", []) + if (!loops.length) return "No active loops." + return loops.map((l) => `[${l.id}] ${l.interval}: "${l.prompt}"`).join("\n") + } + if (args.action === "remove") { + let loops = readJson>("loops.json", []) + loops = loops.filter((l) => l.id !== args.loopId) + writeJson("loops.json", loops) + return `Removed loop ${args.loopId}.` + } + if (!args.prompt) return "Provide a prompt." + const id = Math.random().toString(36).substring(2, 8) + const interval = args.interval || "10m" + const loops = readJson>( + "loops.json", + [], + ) + loops.push({ id, prompt: args.prompt, interval, createdAt: new Date().toISOString() }) + writeJson("loops.json", loops) + return `Loop [${id}] scheduled every ${interval}: "${args.prompt}"` + }, + }), + }, + } + }, +}