diff --git a/tests/claude-quota.test.ts b/tests/claude-quota.test.ts index 09c267a..f4ecd4c 100644 --- a/tests/claude-quota.test.ts +++ b/tests/claude-quota.test.ts @@ -120,171 +120,52 @@ test("reads credentials file and sends OAuth usage request", async () => { await tracker.dispose(); }); -test("missing credentials file yields no status", async () => { - let calls = 0; - const tracker = new ClaudeQuotaTracker( - async () => {}, - async () => { - calls++; - return Response.json(payload); - }, - 60_000, - path, - ); - await tracker.init(); - expect(calls).toBe(0); - expect(tracker.status()).toBeUndefined(); - await tracker.dispose(); -}); - -test("expired token skips fetch and keeps last snapshot", async () => { - await writeFile(path, credentials(Date.now() + 3_600_000)); - let calls = 0; - const tracker = new ClaudeQuotaTracker( - async () => {}, - async () => { - calls++; - return Response.json(payload); - }, - 60_000, - path, - 1, - ); - await tracker.init(); - await writeFile(path, credentials(Date.now() - 1)); - await tracker.refresh(); - expect(calls).toBe(1); - expect(tracker.status()?.windows).toHaveLength(4); - await tracker.dispose(); -}); - -test("failed fetch retries with re-read credentials", async () => { - await writeFile(path, credentials(Date.now() + 3_600_000)); - const tokens: Array = []; - let changed = 0; - const tracker = new ClaudeQuotaTracker( - async () => { - changed++; - }, - async (_input, init) => { - tokens.push(new Headers(init?.headers).get("authorization")); - if (tokens.length > 1) return Response.json(payload); - await writeFile( - path, - credentials(Date.now() + 3_600_000).replace("token-123", "token-456"), - ); - throw new Error("network down"); - }, - 60_000, - path, - 1, - ); - await tracker.init(); - expect(tokens).toEqual(["Bearer token-123", "Bearer token-456"]); - expect(tracker.status()?.plan).toBe("max"); - expect(changed).toBe(1); - await tracker.dispose(); -}); - -test("corrupt credentials file keeps previous status and retries", async () => { - await writeFile(path, credentials(Date.now() + 3_600_000)); - let calls = 0; - let changed = 0; - const tracker = new ClaudeQuotaTracker( - async () => { - changed++; - }, - async () => { - calls++; - return Response.json(payload); - }, - 60_000, - path, - 1, - ); - await tracker.init(); - await writeFile(path, "{not json"); - await tracker.refresh(); - await writeFile(path, JSON.stringify({ claudeAiOauth: { accessToken: 1 } })); - await tracker.refresh(); - expect(calls).toBe(1); - expect(changed).toBe(1); - expect(tracker.status()?.windows).toHaveLength(4); - await tracker.dispose(); -}); - -test("missing credentials file clears status without retry", async () => { - await writeFile(path, credentials(Date.now() + 3_600_000)); - let calls = 0; - let changed = 0; - const tracker = new ClaudeQuotaTracker( - async () => { - changed++; - }, - async () => { - calls++; - return Response.json(payload); - }, - 60_000, - path, - 10_000, - ); - await tracker.init(); - await rm(path); - const start = performance.now(); - await tracker.refresh(); - expect(performance.now() - start).toBeLessThan(1_000); - expect(calls).toBe(1); - expect(changed).toBe(2); - expect(tracker.status()).toBeUndefined(); - await tracker.dispose(); -}); - -test("gives up after five failed attempts", async () => { - await writeFile(path, credentials(Date.now() + 3_600_000)); - let calls = 0; - const tracker = new ClaudeQuotaTracker( - async () => {}, - async () => { - calls++; - return new Response("nope", { status: 500 }); - }, - 60_000, - path, - 1, - ); - await tracker.init(); - expect(calls).toBe(5); - expect(tracker.status()).toBeUndefined(); - await tracker.refresh(); - expect(calls).toBe(10); - await tracker.dispose(); -}); - -test("dispose during backoff resolves quickly", async () => { - await writeFile(path, credentials(Date.now() + 3_600_000)); - let calls = 0; - let changed = 0; - const tracker = new ClaudeQuotaTracker( - async () => { - changed++; - }, - async () => { - calls++; - throw new Error("network down"); - }, - 60_000, - path, - 60_000, - ); - const init = tracker.init(); - while (calls === 0) await new Promise((resolve) => setTimeout(resolve, 1)); - await new Promise((resolve) => setTimeout(resolve, 5)); - const start = performance.now(); - await tracker.dispose(); - await init; - expect(performance.now() - start).toBeLessThan(1_000); - expect(calls).toBe(1); - expect(changed).toBe(0); - expect(tracker.status()).toBeUndefined(); +test("failed fetches retry with re-read credentials and give up after five attempts", async () => { + { + await writeFile(path, credentials(Date.now() + 3_600_000)); + const tokens: Array = []; + let changed = 0; + const tracker = new ClaudeQuotaTracker( + async () => { + changed++; + }, + async (_input, init) => { + tokens.push(new Headers(init?.headers).get("authorization")); + if (tokens.length > 1) return Response.json(payload); + await writeFile( + path, + credentials(Date.now() + 3_600_000).replace("token-123", "token-456"), + ); + throw new Error("network down"); + }, + 60_000, + path, + 1, + ); + await tracker.init(); + expect(tokens).toEqual(["Bearer token-123", "Bearer token-456"]); + expect(tracker.status()?.plan).toBe("max"); + expect(changed).toBe(1); + await tracker.dispose(); + } + { + await writeFile(path, credentials(Date.now() + 3_600_000)); + let calls = 0; + const tracker = new ClaudeQuotaTracker( + async () => {}, + async () => { + calls++; + return new Response("nope", { status: 500 }); + }, + 60_000, + path, + 1, + ); + await tracker.init(); + expect(calls).toBe(5); + expect(tracker.status()).toBeUndefined(); + await tracker.refresh(); + expect(calls).toBe(10); + await tracker.dispose(); + } }); diff --git a/tests/composite.test.ts b/tests/composite.test.ts deleted file mode 100644 index 63bbe00..0000000 --- a/tests/composite.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, PubSub, Schema, Stream } from "effect"; -import { Agent, Model, Provider } from "@opencode/plugin"; -import { Session } from "@opencode/schema/session"; -import { SessionMessage } from "@opencode/schema/session-message"; -import { Tool } from "@opencode/schema/tool"; -import { AbsolutePath } from "@opencode/schema/schema"; -import type { Context } from "@opencode/plugin/effect/plugin"; -import type { AgentEditor } from "@opencode/plugin/effect/agent"; -import type { ToolEditor } from "@opencode/plugin/effect/tool"; -import type { SkillEditor } from "@opencode/plugin/effect/skill"; -import type { SessionHooks } from "@opencode/plugin/effect/session"; -import { z } from "zod"; -import agentsPlugin from "../src/agents.js"; -import multiWorkPlugin from "../src/multi-work.js"; -import { fromPromise } from "@opencode/plugin/promise/adapter"; -import { AgentsRpc } from "../src/agents-rpc.js"; - -const plugin = { - effect: (context: Context) => - Effect.gen(function* () { - yield* fromPromise(agentsPlugin).effect(context); - yield* multiWorkPlugin.effect(context); - }), -}; -import { command } from "../src/herdr.js"; - -// This is a composite plugin host fixture, not OpenCode's native executor/job runtime. -// The real Promise adapter validates API payloads; registries replay from fresh drafts. -const directories: string[] = []; -afterEach(async () => { - for (const directory of directories.splice(0)) - await rm(directory, { recursive: true, force: true }); -}); -interface LocationInstance { - agents: Map>>; - admit: () => Effect.Effect; -} -const scenario = async () => { - const directory = await mkdtemp(join(tmpdir(), "composite-")); - directories.push(directory); - const root = join(directory, "root"); - const destination = join(directory, "child"); - const bin = join(directory, "bin"); - await mkdir(root); - await mkdir(bin); - await mkdir(join(directory, "store")); - const signal = new AbortController().signal; - await command(["git", "init", "-b", "main"], root, signal); - await command( - [ - "git", - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.test", - "commit", - "--allow-empty", - "-m", - "fixture", - ], - root, - signal, - ); - // Fake Herdr, real isolated Git operations. No real Herdr server is contacted. - const inventory = (path: string, id: string) => - JSON.stringify({ - result: { - source: { - repo_key: join(root, ".git"), - repo_root: root, - source_checkout_path: root, - source_workspace_id: "w1", - }, - worktrees: [ - { - path: root, - is_linked_worktree: false, - is_bare: false, - open_workspace_id: "w1", - }, - ...(path === root - ? [] - : [ - { - path, - is_linked_worktree: true, - is_bare: false, - open_workspace_id: id, - }, - ]), - ], - }, - }); - const cli = join(bin, "herdr"); - await writeFile( - cli, - `#!/bin/sh\ncase "$1 $2" in\n"worktree create") git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '${JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } })}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`, - ); - await chmod(cli, 0o755); - const sessions = new Map(); - const parent = Schema.decodeUnknownSync(Session.Info)({ - id: "ses_parent", - projectID: "fixture", - agent: "multi-work", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - location: { directory: root }, - }); - sessions.set(parent.id, parent); - const admissions: string[] = []; - return { - directory, - root, - destination, - bin, - sessions, - instances: new Map(), - admissions, - }; -}; - -function host(fixture: Awaited>, directory: string) { - const location = { directory }; - const agents = new Map>>(); - const transforms: ((editor: AgentEditor) => void)[] = []; - const prompts: ((event: SessionHooks["prompt"]) => Effect.Effect)[] = []; - const tools = new Map(); - const toolTransforms: ((editor: ToolEditor) => void)[] = []; - let lateBuild: Agent.Info["permissions"] = []; - let lateMulti: Agent.Info["permissions"] = []; - let reloads = 0; - const updates = Effect.runSync( - PubSub.unbounded>(), - ); - const registration = Effect.succeed({ dispose: Effect.void }); - let permissions: (() => Effect.Effect) | undefined; - const editAgents: AgentEditor = { - get: (id) => agents.get(id), - list: () => [...agents.values()], - default() {}, - remove: (id) => { - agents.delete(id); - }, - update(id, update) { - const draft = agents.get(id) ?? { - ...Agent.Info.default(Agent.ID.make(id)), - permissions: [], - }; - update(draft); - agents.set(id, draft); - }, - }; - const replay = () => { - agents.clear(); - agents.set("build", Agent.Info.default(Agent.ID.make("build"))); - for (const transform of transforms) transform(editAgents); - // Match the ordering under review: configuration runs AFTER both plugin transforms. - editAgents.update("build", (agent) => { - agent.permissions.push(...lateBuild); - }); - if (agents.has("multi-work")) - editAgents.update("multi-work", (agent) => { - agent.permissions.push(...lateMulti); - }); - }; - const reload = () => - Effect.gen(function* () { - replay(); - reloads++; - yield* PubSub.publish( - updates, - Schema.decodeUnknownSync(Agent.Event.Updated)({ - id: `evt_${reloads}`, - created: Date.now(), - type: "agent.updated", - location, - data: {}, - }), - ); - }); - const editTools: ToolEditor = { - get: (id) => { - const tool = tools.get(id); - return tool && { ...tool, id }; - }, - list: () => [...tools.values()].map((tool) => ({ ...tool, id: tool.name })), - add: (tool) => { - tools.set(tool.name, tool); - }, - namespace() {}, - remove: (id) => { - tools.delete(id); - }, - update(id, update) { - const tool = tools.get(id); - if (tool) { - const draft = { ...tool }; - update(draft); - tools.set(id, draft); - } - }, - }; - const native: Tool.Info = { - name: "subagent", - description: "fixture executor", - input: z.object({}), - execute: (raw, call) => - Effect.gen(function* () { - const input = z.object({ sessionID: z.string().optional() }).parse(raw); - if (!input.sessionID) { - const parent = fixture.sessions.get(call.sessionID)!; - fixture.sessions.set("ses_child", { - ...parent, - id: Session.ID.make("ses_child"), - parentID: parent.id, - agent: Agent.ID.make("coordinate"), - }); - } - yield* call.progress({ sessionID: "ses_child", status: "running" }); - const child = fixture.sessions.get("ses_child")!; - yield* fixture.instances.get(child.location.directory)!.admit(); - fixture.admissions.push(child.location.directory); - return { content: "fixture result", metadata: { sessionID: "ses_child" } }; - }), - }; - const store = (key: string) => - join(fixture.directory, "store", `${encodeURIComponent(key)}.json`); - const providerID = Provider.ID.make("fixture"); - const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({ - ...Model.Info.default(providerID, Model.ID.make(id)), - enabled: true, - })); - // The adapter reads these domain objects at construction; unimplemented operations - // stay undefined and fail loudly if the plugin starts using them. - const context = { - options: { - agents: { - "fast-lo": "fixture/gpt-6-luna#low", - "fast-hi": "fixture/gpt-6-luna#high", - "good-lo": "fixture/gpt-6.1-sol#low", - "good-hi": "fixture/gpt-6.1-sol#high", - "deep-lo": "fixture/gpt-6-astra#low", - "deep-hi": "fixture/gpt-6-astra#high", - }, - }, - location, - app: { version: "2.0.22-fixture" }, - command: {}, - experimental: { terminal: {} }, - generate: {}, - provider: {}, - permission: {}, - plugin: { - list: () => - Effect.succeed({ - location, - data: [ - { - id: "stanp-opencode.multi-work", - source: { type: "builtin" }, - state: { status: "active" }, - features: { server: true }, - }, - ], - }), - }, - reference: {}, - skill: { - transform: (callback: (editor: SkillEditor) => void) => - Effect.sync(() => { - const skills = new Map>>(); - callback({ - list: () => [...skills.values()], - get: (id) => skills.get(id), - add: (skill) => { - skills.set(skill.id, skill); - }, - update(id, update) { - const skill = skills.get(id); - if (skill) update(skill); - }, - remove: (id) => { - skills.delete(id); - }, - }); - }).pipe(Effect.andThen(registration)), - }, - vcs: { branch: {} }, - websearch: {}, - worktree: {}, - model: { list: () => Effect.succeed({ location, data: models }) }, - event: { subscribe: () => Stream.fromPubSub(updates) }, - integration: { - connect: {}, - oauth: {}, - command: {}, - connection: { - active: () => Effect.succeed(undefined), - resolve: () => Effect.succeed(undefined), - }, - }, - rpc: Object.assign(() => ({ permissions: () => permissions!() }), { - register: ( - definition: typeof AgentsRpc, - handlers: { permissions: () => Effect.Effect }, - ) => - Effect.sync(() => { - expect(definition.id).toBe(AgentsRpc.id); - permissions = handlers.permissions; - return { dispose: Effect.void, events: { emit: () => Effect.void } }; - }), - }), - mcp: { - transform: (callback: (editor: { get: () => undefined }) => void) => - Effect.sync(() => callback({ get: () => undefined })).pipe( - Effect.andThen(registration), - ), - }, - storage: { - get: (key: string) => - Effect.promise(async () => { - try { - return JSON.parse(await readFile(store(key), "utf8")); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") - return undefined; - throw error; - } - }), - set: (key: string, value: Schema.Json) => - Effect.promise(() => writeFile(store(key), JSON.stringify(value))), - }, - agent: { - list: (input?: { location?: { directory?: string } }) => - Effect.sync(() => ({ - location, - data: [ - ...( - fixture.instances.get(input?.location?.directory ?? directory) - ?.agents ?? agents - ).values(), - ], - })), - get: ({ agentID }: { agentID: string }) => - Effect.sync(() => ({ location, data: agents.get(agentID)! })), - transform: (callback: (editor: AgentEditor) => void) => - Effect.sync(() => { - transforms.push(callback); - replay(); - }).pipe(Effect.andThen(registration)), - reload, - }, - tool: { - transform: (callback: (editor: ToolEditor) => void) => - Effect.sync(() => { - toolTransforms.push(callback); - tools.clear(); - tools.set(native.name, native); - for (const transform of toolTransforms) transform(editTools); - }).pipe(Effect.andThen(registration)), - hook: () => registration, - }, - session: { - get: ({ sessionID }: { sessionID: string }) => - Effect.sync(() => fixture.sessions.get(sessionID)!), - move: ({ sessionID, directory }: { sessionID: string; directory: string }) => - Effect.sync(() => { - const child = fixture.sessions.get(sessionID)!; - fixture.sessions.set(sessionID, { - ...child, - location: { directory: AbsolutePath.make(directory) }, - }); - }), - hook: ( - name: string, - callback: (event: SessionHooks["prompt"]) => Effect.Effect, - ) => - Effect.sync(() => { - if (name === "prompt") prompts.push(callback); - }).pipe(Effect.andThen(registration)), - }, - }; - // SAFETY: Exercise the exported composite through the real Promise adapter. The - // fixture implements precisely the accessed services; unused operations fail. - const install = plugin.effect as ( - context: Pick, - ) => ReturnType; - const call: Tool.Context = { - sessionID: Session.ID.make("ses_parent"), - agent: Agent.ID.make("multi-work"), - messageID: SessionMessage.ID.make("msg_fixture"), - id: Tool.CallID.make("call_fixture"), - progress: () => Effect.void, - }; - const result = { - agents, - install: install(context), - replay: reload, - get reloads() { - return reloads; - }, - configure(build: Agent.Info["permissions"], multi: Agent.Info["permissions"] = []) { - lateBuild = build; - lateMulti = multi; - return reload(); - }, - resume: () => - tools.get("subagent")!.execute( - { - agent: "coordinate", - sessionID: "ses_child", - description: "resume", - prompt: "continue", - }, - call, - ), - create: () => - tools.get("subagent")!.execute( - { - agent: "coordinate", - worktree: { base: "main" }, - description: "new", - prompt: "work", - }, - call, - ), - admit: () => - Effect.forEach(prompts, (hook) => - hook({ - sessionID: Session.ID.make("ses_child"), - messageID: SessionMessage.ID.make("msg_child"), - prompt: { text: "continue" }, - delivery: "steer", - metadata: {}, - }), - ), - }; - fixture.instances.set(directory, result); - return result; -} - -test("exported composite inherits late build permissions, remains stable over two replays, and preserves later overrides", async () => { - const f = await scenario(); - const oldPath = process.env.PATH; - process.env.PATH = `${f.bin}:${oldPath}`; - try { - const h = host(f, f.root); - const destination = host(f, f.destination); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* h.install; - yield* destination.install; - expect(h.agents.has("multi-work")).toBe(true); - expect(h.agents.get("multi-work")?.model).toEqual( - h.agents.get("coordinate")?.model, - ); - expect(h.agents.get("multi-work")?.request).toEqual( - h.agents.get("coordinate")?.request, - ); - const before = h.reloads; - yield* h.configure([ - { action: "herdr_worktree", resource: "*", effect: "deny" }, - ]); - yield* Effect.gen(function* () { - while (h.reloads < before + 2) yield* Effect.sleep("1 millis"); - }).pipe(Effect.timeout("2 seconds")); - const inherited = () => - h.agents - .get("multi-work")! - .permissions.filter((rule) => rule.action === "herdr_worktree"); - const expected = [...inherited()]; - expect(expected.at(-1)?.effect).toBe("deny"); - for (let replay = 0; replay < 2; replay++) { - yield* h.replay(); - expect(inherited()).toEqual(expected); - expect((yield* Effect.exit(h.create()))._tag).toBe("Failure"); - } - yield* h.configure( - [{ action: "herdr_worktree", resource: "*", effect: "deny" }], - [{ action: "herdr_worktree", resource: "*", effect: "allow" }], - ); - expect((yield* h.create()).content).toContain("fixture result"); - const parent = f.sessions.get("ses_parent")!; - f.sessions.set(parent.id, { - ...parent, - permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }], - }); - expect((yield* Effect.exit(h.resume()))._tag).toBe("Failure"); - }), - ), - ); - } finally { - process.env.PATH = oldPath; - } -}, 15_000); - -test("separate exported composite instances admit a disk-bound child and resume after plugin scopes restart", async () => { - const f = await scenario(); - const oldPath = process.env.PATH; - process.env.PATH = `${f.bin}:${oldPath}`; - try { - for (let restart = 0; restart < 2; restart++) { - const root = host(f, f.root); - const destination = host(f, f.destination); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* root.install; - yield* destination.install; - expect(root.agents.has("multi-work")).toBe(true); - expect(destination.agents.has("multi-work")).toBe(false); - if (restart === 0) yield* root.create(); - yield* destination.admit(); - expect((yield* root.resume()).content).toContain(f.destination); - const child = f.sessions.get("ses_child")!; - f.sessions.set(child.id, { - ...child, - location: { directory: AbsolutePath.make(f.root) }, - }); - expect((yield* Effect.exit(destination.admit()))._tag).toBe("Failure"); - f.sessions.set(child.id, child); - }), - ), - ); - } - expect(f.admissions).toEqual([f.destination, f.destination, f.destination]); - } finally { - process.env.PATH = oldPath; - } -}, 15_000); diff --git a/tests/herdr.test.ts b/tests/herdr.test.ts index 5bc11fc..60769b6 100644 --- a/tests/herdr.test.ts +++ b/tests/herdr.test.ts @@ -157,67 +157,6 @@ test("create verifies a linked workspace whose inventory source remains the pare ); }); -test("create passes an optional workspace label to Herdr", async () => { - const f = await fixture(); - const root = (await f.herdr.root(f.root, f.signal))!; - const destination = await f.herdr.create( - root, - "main", - "fix/login-timeout", - f.signal, - "Fix login timeout", - ); - expect(destination.branch).toBe("fix/login-timeout"); - expect(f.label).toBe("Fix login timeout"); - const creation = f.calls.find((args) => args[0] === "herdr" && args[2] === "create")!; - expect(creation.slice(creation.indexOf("--label"), creation.indexOf("--label") + 2)).toEqual([ - "--label", - "Fix login timeout", - ]); -}); - -test("create rejects an existing destination branch before invoking Herdr", async () => { - const f = await fixture(); - const root = (await f.herdr.root(f.root, f.signal))!; - await expect( - f.herdr.create(root, "main", "invalid branch", f.signal, "Invalid"), - ).rejects.toThrow(); - await expect(f.herdr.create(root, "main", "other", f.signal, "Other")).rejects.toThrow( - "Destination branch already exists: other", - ); - expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0); -}); - -test("verify rejects changed parent, child and repository identities", async () => { - for (const change of [ - { sourceWorkspaceID: "w9" }, - { sourcePath: "child" }, - { childWorkspaceID: "w9" }, - { childPath: "root" }, - { repo: "child" }, - ]) { - const f = await fixture(); - const root = (await f.herdr.root(f.root, f.signal))!; - const destination = await f.herdr.create(root, "main", "multi-work/fixture", f.signal); - f.changeInventory({ - sourceWorkspaceID: change.sourceWorkspaceID, - sourcePath: change.sourcePath === "child" ? f.destination : undefined, - childWorkspaceID: change.childWorkspaceID, - childPath: change.childPath === "root" ? f.root : undefined, - repo: change.repo === "child" ? f.destination : undefined, - }); - await expect( - f.herdr.verify( - destination.directory, - destination.workspaceID, - root, - destination.branch, - f.signal, - ), - ).rejects.toThrow("Bound Herdr checkout is no longer available"); - } -}); - test("closed, linked and ambiguous workspaces fail root gating", async () => { for (const mode of ["close", "link", "duplicate"] as const) { const f = await fixture(); @@ -225,27 +164,3 @@ test("closed, linked and ambiguous workspaces fail root gating", async () => { expect(await f.herdr.root(f.root, f.signal)).toBeUndefined(); } }); - -test("revalidates before mutation and rejects non-branch refs without creating a worktree", async () => { - const f = await fixture(); - const root = (await f.herdr.root(f.root, f.signal))!; - const oid = await command(["git", "rev-parse", "HEAD"], f.root, f.signal); - for (const base of [oid, "--help", "main; touch injected"]) { - await expect(f.herdr.create(root, base, "multi-work/fixture", f.signal)).rejects.toThrow(); - } - f.close(); - await expect(f.herdr.create(root, "main", "multi-work/fixture", f.signal)).rejects.toThrow( - "no longer eligible", - ); - expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0); -}); - -test("a missing or malformed Herdr CLI never qualifies", async () => { - const f = await fixture(); - await expect(new Herdr(async () => "not json").root(f.root, f.signal)).rejects.toThrow(); - await expect( - new Herdr(async () => { - throw new Error("ENOENT"); - }).root(f.root, f.signal), - ).rejects.toThrow("ENOENT"); -}); diff --git a/tests/install.test.ts b/tests/install.test.ts index acfda0d..0eb181b 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -1,15 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { - chmod, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - stat, - symlink, - writeFile, -} from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createHash } from "node:crypto"; @@ -24,7 +14,11 @@ const unrelated = { permissions: { shell: "ask" }, mcp: { servers: { - codegraph: { type: "local", command: ["user-codegraph"], codemode: true }, + codegraph: { + type: "local", + command: ["user-codegraph"], + codemode: true, + }, browser: { type: "local", command: ["user-browser"] }, }, }, @@ -83,13 +77,20 @@ async function fixture() { join(source, "dist", "tui.js"), 'export default { id: "stanp-opencode.quota-tui", setup() {} };', ); - const built = await Bun.build({ entrypoints: [helper.pathname], target: "bun", format: "esm" }); + const built = await Bun.build({ + entrypoints: [helper.pathname], + target: "bun", + format: "esm", + }); expect(built.success).toBe(true); await Bun.write(join(source, "dist", "install.js"), built.outputs[0]!); await writeFile(join(source, "package.json"), '{"type":"module"}'); await writeFile( join(config, "opencode.json"), - JSON.stringify({ ...unrelated, plugins: [{ package: "before", options: { keep: true } }] }), + JSON.stringify({ + ...unrelated, + plugins: [{ package: "before", options: { keep: true } }], + }), ); await writeFile(join(config, "opencode.jsonc"), "// untouched user config\n{}\n"); return { directory, source, config }; @@ -98,20 +99,13 @@ async function contents(config: string) { return JSON.parse(await readFile(join(config, "opencode.json"), "utf8")); } -for (const selection of [ - "agents", - "quota", - "agents,quota", - "agents,multi-work", - "agents,multi-work,quota", - "optchat", - "agents,multi-work,quota,optchat", -]) { +for (const selection of ["quota", "agents,multi-work,quota,optchat"]) { test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => { const f = await fixture(); await install(f.source, f.config, selection); const result = await contents(f.config); expect(result).toMatchObject(unrelated); + expect(result.experimental === undefined).toBe(selection === "quota"); expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([ "before", ...selection.split(",").map((name) => `./plugins/stanp-opencode/${name}`), @@ -148,96 +142,7 @@ test("upgrades retain selection and options; explicit selection replaces only ow ]); }); -test("mixed string/object registrations upgrade without flags and preserve unrelated entries verbatim", async () => { - const f = await fixture(); - const before = "unrelated-plugin"; - const after = { package: "unrelated-object", options: { keep: [1, true, "value"] } }; - await writeFile( - join(f.config, "opencode.json"), - JSON.stringify({ - ...unrelated, - plugins: [before, { package: "./plugins/stanp-opencode/quota" }, after], - }), - ); - await install(f.source, f.config); - expect((await contents(f.config)).plugins).toEqual([ - before, - { package: "./plugins/stanp-opencode/quota" }, - after, - ]); - await install(f.source, f.config, "agents,quota"); - const updated = await contents(f.config); - expect(updated.plugins).toEqual([ - before, - { - package: "./plugins/stanp-opencode/agents", - options: empty, - }, - { package: "./plugins/stanp-opencode/quota" }, - after, - ]); - expect(updated).toMatchObject(unrelated); -}); - -test("owned string registrations retain their selection and explicit selection removes mixed duplicates", async () => { - const f = await fixture(); - await writeFile( - join(f.config, "opencode.json"), - JSON.stringify({ - plugins: [ - "before", - "./plugins/stanp-opencode/agents", - "./plugins/stanp-opencode/multi-work", - "./plugins/stanp-opencode/quota", - { package: "stanp-opencode/quota" }, - "after", - ], - }), - ); - await install(f.source, f.config); - const retained = await contents(f.config); - expect(retained.plugins).toEqual([ - "before", - { - package: "./plugins/stanp-opencode/agents", - options: empty, - }, - { package: "./plugins/stanp-opencode/multi-work" }, - { package: "./plugins/stanp-opencode/quota" }, - "after", - ]); - await install(f.source, f.config, "quota"); - expect((await contents(f.config)).plugins).toEqual([ - "before", - { package: "./plugins/stanp-opencode/quota" }, - "after", - ]); -}); - -for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absolute"]) { - test(`owned monolith string ${target} upgrades without a selection`, async () => { - const f = await fixture(); - const packageName = - target === "absolute" ? join(f.config, "plugins", "stanp-opencode") : target; - await writeFile( - join(f.config, "opencode.json"), - JSON.stringify({ plugins: ["before", packageName, "after"] }), - ); - await install(f.source, f.config); - expect((await contents(f.config)).plugins).toEqual([ - "before", - { - package: "./plugins/stanp-opencode/agents", - options: empty, - }, - { package: "./plugins/stanp-opencode/multi-work" }, - { package: "./plugins/stanp-opencode/quota" }, - "after", - ]); - }); -} - -test("installer writes empty option skeletons, fills only missing keys, and is idempotent", async () => { +test("installer writes empty option skeletons and subagent_depth, fills only missing keys, and is idempotent", async () => { const f = await fixture(); await install(f.source, f.config, "agents,multi-work,quota,optchat"); const fresh = await contents(f.config); @@ -250,7 +155,11 @@ test("installer writes empty option skeletons, fills only missing keys, and is i options: { compactionModel: "", dataDir: "" }, }, ]); - fresh.plugins[1].options = { agents: { "fast-lo": "openai/gpt-6-luna#low" }, extra: 1 }; + expect(fresh.experimental).toEqual({ subagent_depth: 2 }); + fresh.plugins[1].options = { + agents: { "fast-lo": "openai/gpt-6-luna#low" }, + extra: 1, + }; fresh.plugins[4].options = { compactionModel: "acme/cheap" }; await writeFile(join(f.config, "opencode.json"), JSON.stringify(fresh)); await install(f.source, f.config); @@ -259,40 +168,35 @@ test("installer writes empty option skeletons, fills only missing keys, and is i agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" }, extra: 1, }); - expect(filled.plugins[4].options).toEqual({ compactionModel: "acme/cheap", dataDir: "" }); + expect(filled.plugins[4].options).toEqual({ + compactionModel: "acme/cheap", + dataDir: "", + }); expect(filled).toMatchObject(unrelated); await install(f.source, f.config); expect(await contents(f.config)).toEqual(filled); -}); - -test("agents installs set experimental.subagent_depth to at least 2", async () => { - const f = await fixture(); - await install(f.source, f.config, "quota"); - expect((await contents(f.config)).experimental).toBeUndefined(); - await install(f.source, f.config, "agents"); - const fresh = await contents(f.config); - expect(fresh.experimental).toEqual({ subagent_depth: 2 }); - expect(fresh).toMatchObject(unrelated); for (const [depth, expected] of [ [1, 2], [3, 3], ]) { await writeFile( join(f.config, "opencode.json"), - JSON.stringify({ ...fresh, experimental: { subagent_depth: depth, other: true } }), + JSON.stringify({ + ...filled, + experimental: { subagent_depth: depth, other: true }, + }), ); await install(f.source, f.config); - const result = await contents(f.config); - expect(result.experimental).toEqual({ subagent_depth: expected, other: true }); - await install(f.source, f.config); - expect(await contents(f.config)).toEqual(result); + expect((await contents(f.config)).experimental).toEqual({ + subagent_depth: expected, + other: true, + }); } }); -test("local installer retains executable permissions and supports direct invocation", async () => { +test("local installer supports direct invocation", async () => { const f = await fixture(); const path = new URL("../install-local.sh", import.meta.url).pathname; - expect((await stat(path)).mode & 0o777).toBe(0o755); const result = await run([path, "--plugins", "multi-work"], f.directory); expect(result.exitCode).toBe(2); expect(result.stderr).toContain("Multi-work requires Agents"); @@ -348,43 +252,6 @@ test("noninteractive fresh installs require a selection; invalid selections leav expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original); }); -test("interactive Multi-work-only selection offers Agents", async () => { - const f = await fixture(); - const command = `${process.execPath} '${helper.pathname}' '${f.source}' '${f.config}'`; - const child = Bun.spawn(["script", "-qec", command, "/dev/null"], { - cwd: f.directory, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - timeout: 10_000, - }); - let output = ""; - let selected = false; - let confirmed = false; - const reader = child.stdout.getReader(); - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - output += new TextDecoder().decode(chunk.value); - if (!selected && output.includes("Plugins (")) { - child.stdin.write("multi-work\n"); - selected = true; - } - if (!confirmed && output.includes("Add Agents?")) { - child.stdin.write("y\n"); - confirmed = true; - } - } - expect(await child.exited, output).toBe(0); - child.stdin.end(); - expect(output).toContain("Add Agents?"); - expect( - (await contents(f.config)).plugins - .slice(1) - .map((entry: { package: string }) => entry.package), - ).toEqual(["./plugins/stanp-opencode/agents", "./plugins/stanp-opencode/multi-work"]); -}); - test("artifact validation failure leaves prior installation and config intact", async () => { const f = await fixture(); await install(f.source, f.config, "quota"); @@ -406,36 +273,6 @@ test("artifact validation failure leaves prior installation and config intact", expect(await readdir(join(f.config, "plugins"))).toEqual(["stanp-opencode"]); }); -test("replaces an owned installation symlink without touching its target or unrelated plugin directories", async () => { - const f = await fixture(); - const external = join(f.directory, "old installation"); - const sibling = join(f.config, "plugins", "unrelated"); - await mkdir(external); - await mkdir(sibling, { recursive: true }); - await writeFile(join(external, "keep.txt"), "old work is untouched"); - await writeFile(join(sibling, "index.js"), "unrelated plugin"); - await symlink(external, join(f.config, "plugins", "stanp-opencode")); - await install(f.source, f.config, "quota"); - expect(await readFile(join(external, "keep.txt"), "utf8")).toBe("old work is untouched"); - expect(await readFile(join(sibling, "index.js"), "utf8")).toBe("unrelated plugin"); - expect( - await Bun.file(join(f.config, "plugins", "stanp-opencode", "quota", "index.js")).exists(), - ).toBe(true); -}); - -test("both shell installers reject explicit Multi-work-only selection before doing work", async () => { - const f = await fixture(); - for (const file of ["install.sh", "install-local.sh"]) { - const result = await run( - ["bash", new URL(`../${file}`, import.meta.url).pathname, "--plugins", "multi-work"], - f.directory, - ); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("Multi-work requires Agents"); - } - expect((await contents(f.config)).plugins).toHaveLength(1); -}); - test("local shell installer builds its checkout and upgrades mixed entries without flags", async () => { const f = await fixture(); const after = { package: "unrelated-object", options: { keep: true } }; @@ -481,7 +318,9 @@ for (const validChecksum of [true, false]) if (validChecksum) await writeFile( join(f.config, "opencode.json"), - JSON.stringify({ plugins: ["unrelated-plugin", "./plugins/stanp-opencode/quota"] }), + JSON.stringify({ + plugins: ["unrelated-plugin", "./plugins/stanp-opencode/quota"], + }), ); const archiveRoot = join(f.directory, "archive"); await mkdir(archiveRoot); diff --git a/tests/multi-work.test.ts b/tests/multi-work.test.ts index 3f78dfe..bbd22aa 100644 --- a/tests/multi-work.test.ts +++ b/tests/multi-work.test.ts @@ -326,54 +326,6 @@ const request = z.object({ background: z.boolean().optional(), }); -test("Effect-native wrapper preserves name, metadata and result; moves before prompt and resumes its bound child", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - expect(f.agents.get("multi-work")?.mode).toBe("primary"); - expect(f.tool.name).toBe("subagent"); - expect(f.tool.options?.codemode).toBe(false); - expect(f.agents.get("coordinate")?.mode).toBe("all"); - const result = yield* f.invoke(); - expect(result.content).toStartWith("native result"); - expect(result.content).toContain("Checkout: /worktree"); - expect(result.metadata?.sessionID).toBe("ses_child1"); - expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]); - expect(f.parent.location.directory).toBe("/root"); - expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe( - true, - ); - const resume = yield* f.execute({ - agent: "coordinate", - description: "resume", - prompt: "continue", - sessionID: "ses_child1", - background: true, - }); - expect(resume.metadata?.status).toBe("running"); - expect(f.herdr.creations).toBe(1); - expect(f.herdr.verified).toBe(1); - }), - ), - ); -}); - -test("local fast-lo uses native delegation without a worktree", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.execute({ agent: "fast-lo", description: "lookup", prompt: "read" }); - expect(f.herdr.creations).toBe(0); - expect(f.order).toEqual(["create", "prompt:/root"]); - }), - ), - ); -}); - test("coordinate can create and resume a root-local background child without Herdr creation or movement", async () => { const f = fixture(); f.parent.permissions = [{ action: "herdr_worktree", resource: "*", effect: "deny" }]; @@ -496,83 +448,6 @@ test("interrupted root-local creation retains its binding and cannot be blindly expect(f.prompts).toBe(1); }); -test("custom branch and workspace label pass through delegation", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - const result = yield* f.execute({ - agent: "coordinate", - description: "Fix login timeout", - prompt: "work", - worktree: { - base: "main", - branch: "fix/login-timeout", - label: "Fix login timeout", - }, - }); - expect(f.herdr.createdBase).toBe("main"); - expect(f.herdr.createdBranch).toBe("fix/login-timeout"); - expect(f.herdr.createdLabel).toBe("Fix login timeout"); - expect(result.metadata?.branch).toBe("fix/login-timeout"); - }), - ), - ); -}); - -test("omitted naming retains generated branch and no explicit label", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.invoke(); - expect(f.herdr.createdBranch).toMatch(/^multi-work\/[0-9a-f-]{36}$/); - expect(f.herdr.createdLabel).toBeUndefined(); - }), - ), - ); -}); - -test("rejects empty naming fields and naming on resume", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - for (const worktree of [ - { base: "main", branch: " " }, - { base: "main", label: " " }, - ]) - expect( - (yield* Effect.exit( - f.execute({ - agent: "coordinate", - description: "invalid", - prompt: "work", - worktree, - }), - ))._tag, - ).toBe("Failure"); - yield* f.invoke(); - expect( - (yield* Effect.exit( - f.execute({ - agent: "coordinate", - description: "resume", - prompt: "work", - sessionID: "ses_child1", - worktree: { base: "main", branch: "fix/other" }, - }), - ))._tag, - ).toBe("Failure"); - expect(f.herdr.creations).toBe(1); - }), - ), - ); -}); - test("root loss, session permission denial and missing destination fail before prompting", async () => { for (const failure of ["root", "permission", "ask", "destination", "project"] as const) { const f = fixture(); @@ -602,272 +477,3 @@ test("root loss, session permission denial and missing destination fail before p ); } }); - -test("interruption crosses the wrapper into the native executor finalizer", async () => { - const f = fixture(); - f.hold(); - const controller = new AbortController(); - const running = Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - return yield* f.invoke(); - }), - ), - { signal: controller.signal }, - ); - await f.entered.promise; - // entered may resolve at move request; yield until the native foreground wait is reached. - while (f.prompts === 0) await Bun.sleep(1); - controller.abort(); - await expect(running).rejects.toBeDefined(); - expect(f.cancelled).toBe(true); -}); - -test("move admission alone never admits a task; cancellation retains an unready binding", async () => { - const f = fixture(); - f.delayMove(); - const controller = new AbortController(); - const running = Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - return yield* f.invoke(); - }), - ), - { signal: controller.signal }, - ); - await f.entered.promise; - expect(f.prompts).toBe(0); - controller.abort(); - await expect(running).rejects.toBeDefined(); - expect(f.prompts).toBe(0); - expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(false); -}); - -test("rejects foreign and changed-agent resumes, non-primary multi-work and unbound coordinate calls", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.invoke(); - f.sessions.get("ses_child1")!.agent = "fast-lo"; - expect( - (yield* Effect.exit( - f.execute({ - agent: "coordinate", - description: "resume", - prompt: "x", - sessionID: "ses_child1", - }), - ))._tag, - ).toBe("Failure"); - f.parent.parentID = "ses_foreign"; - expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); - f.parent.parentID = undefined; - expect( - (yield* Effect.exit( - f.invoke(undefined, { ...f.call, agent: Agent.ID.make("build") }), - ))._tag, - ).toBe("Failure"); - expect( - (yield* Effect.exit( - f.execute({ agent: "multi-work", description: "x", prompt: "x" }), - ))._tag, - ).toBe("Failure"); - expect(f.herdr.creations).toBe(1); - }), - ), - ); -}); - -test("command-created children require ready owned bindings and cannot bypass placement guards", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.invoke(); - const child = f.sessions.get("ses_child1")!; - const binding = bindingSchema.parse(f.storage.get(bindingKey(child.id))); - const admit = () => f.prompt({ sessionID: Session.ID.make(child.id) }); - yield* admit(); - f.storage.set(bindingKey(child.id), { ...binding, ready: false }); - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - f.storage.set(bindingKey(child.id), binding); - child.location.directory = "/relocated"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - child.location.directory = binding.directory; - child.projectID = "foreign"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - child.projectID = binding.projectID; - f.parent.location.directory = "/elsewhere"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - f.parent.location.directory = binding.root.directory; - f.parent.agent = "build"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - f.parent.agent = "multi-work"; - child.agent = "multi-work"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - child.agent = "fast-hi"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - child.agent = "fast-lo"; - expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); - child.location.directory = f.parent.location.directory; - yield* admit(); - }), - ), - ); -}); - -test("ineligible roots do not register multi-work; multi-work cannot execute shell even with session overrides", async () => { - const f = fixture(); - f.herdr.eligible = false; - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - expect(f.agents.has("multi-work")).toBe(false); - const hook = f.toolHooks[0]!; - expect( - (yield* Effect.exit(hook({ ...f.call, tool: "shell", input: {} })))._tag, - ).toBe("Failure"); - }), - ), - ); -}); - -test("interruption during Herdr creation aborts the command and retains an inspectable attempt without prompting", async () => { - const f = fixture(); - f.herdr.delayCreation = true; - const controller = new AbortController(); - const running = Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - return yield* f.invoke(); - }), - ), - { signal: controller.signal }, - ); - await f.herdr.creationStarted.promise; - controller.abort(); - await expect(running).rejects.toBeDefined(); - expect(f.herdr.creationAborted).toBe(true); - expect(f.prompts).toBe(0); - expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true); -}); - -test("waits for asynchronous destination plugin activation before moving or prompting", async () => { - const f = fixture(); - f.warmDestination(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.invoke(); - expect(f.agentReads).toBe(2); - expect(f.prompts).toBe(1); - expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]); - }), - ), - ); -}); - -test("configured build restrictions remain effective for worktree preparation", async () => { - const f = fixture(); - f.agents.set("build", { - ...Agent.Info.default(Agent.ID.make("build")), - permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }], - }); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); - expect(f.herdr.creations).toBe(0); - }), - ), - ); -}); - -test("worktree resumes retain the herdr_worktree permission guard", async () => { - const f = fixture(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.invoke(); - f.parent.permissions = [ - { action: "herdr_worktree", resource: "*", effect: "deny" }, - ]; - const resumed = yield* Effect.exit( - f.execute({ - agent: "coordinate", - description: "resume", - prompt: "continue", - sessionID: "ses_child1", - }), - ); - expect(resumed._tag).toBe("Failure"); - expect(f.herdr.verified).toBe(0); - expect(f.prompts).toBe(1); - }), - ), - ); -}); - -test("failed initialization records the attempt and never blindly recreates on replay", async () => { - const f = fixture(); - f.missingDestination(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); - expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); - expect(f.herdr.creations).toBe(1); - expect(f.prompts).toBe(0); - const record = z - .object({ child: z.string(), branch: z.string() }) - .parse(f.storage.get(`multi-work-call:${f.call.sessionID}:${f.call.id}`)); - expect(record.child).toBe("ses_child1"); - expect(record.branch).toStartWith("multi-work/"); - }), - ), - ); -}); - -test("same-child concurrent resumes are rejected while the native foreground executor is active", async () => { - const f = fixture(); - f.hold(); - const controller = new AbortController(); - const checked = Promise.withResolvers(); - const running = Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* f.install; - yield* f.invoke().pipe(Effect.forkScoped); - yield* Effect.promise(() => f.entered.promise); - const second = yield* Effect.exit( - f.execute({ - agent: "coordinate", - description: "resume", - prompt: "continue", - sessionID: "ses_child1", - }), - ); - expect(second._tag).toBe("Failure"); - expect(f.herdr.creations).toBe(1); - checked.resolve(); - return yield* Effect.never; - }), - ), - { signal: controller.signal }, - ); - await Promise.race([checked.promise, running]); - controller.abort(); - await expect(running).rejects.toBeDefined(); - expect(f.cancelled).toBe(true); -}); diff --git a/tests/optchat-core.test.ts b/tests/optchat-core.test.ts index 7328d56..4cde2fe 100644 --- a/tests/optchat-core.test.ts +++ b/tests/optchat-core.test.ts @@ -5,17 +5,13 @@ import { VIEW_HIGH, VIEW_LOW, before, - blocks, bytes, clean, compressTask, - contains, grow, key, merge, mergeTask, - pieces, - render, tooLong, viewBytes, type Line, @@ -67,23 +63,7 @@ test("due ordering reproduces Taelin's push at its list length for t=0..2000", ( } }); -test("merging from the first message diverges from push (the recipe's T=10 example)", () => { - const text = () => ""; - // View 0+4, 4+4, 8+1, 9+1 at T=10: push merges 8-9, not 0-7. - const view: Line[] = [ - [2, 0], - [2, 1], - [0, 8], - [0, 9], - ]; - expect(merge(view, 10, viewBytes(view, text) - 1, text)).toEqual([ - [2, 0], - [2, 1], - [1, 4], - ]); -}); - -test("merge skips pairs whose parent is unbuilt", () => { +test("merges and compaction context never use unbuilt lines", () => { const built = new Set(["1:0"]); const text = (line: Line) => (line[0] === 0 || built.has(key(line)) ? "" : undefined); const view: Line[] = [ @@ -97,98 +77,74 @@ test("merge skips pairs whose parent is unbuilt", () => { [0, 2], [0, 3], ]); -}); - -test("view is a sawtooth: grows past 128 KB, then one batch drops it to 64 KB", () => { - const line = "y".repeat(500); - const text = () => line; - const view: View = { lines: [], batching: false }; - let peak = 0; - let batches = 0; - let total = 0; - for (let i = 0; i < 2000; i++) { - const size = viewBytes(view.lines, text); - expect(total).toBe(size); - peak = Math.max(peak, size); - const grown = grow(view, i, text, total, true); - total = grown.total; - if (grown.merged) { - batches++; - expect(size).toBeLessThanOrEqual(VIEW_HIGH); - expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW); - } else expect(view.lines.at(-1)).toEqual([0, i]); - } - expect(peak).toBeGreaterThan(VIEW_LOW); - expect(peak).toBeLessThanOrEqual(VIEW_HIGH + 600); - expect(batches).toBeGreaterThan(1); - // Lines stay contiguous and cover every message. - let next = 0; - for (const [l, i] of view.lines) { - expect(i * 2 ** l).toBe(next); - next += 2 ** l; - } - expect(next).toBe(2000); -}); - -test("a batch that cannot reach 64 KB continues at the next message", () => { - let parents = false; - const text = (line: Line) => (line[0] === 0 || parents ? "z".repeat(500) : undefined); - const view: View = { lines: [], batching: false }; - let total = 0; - for (let i = 0; i < 260; i++) { - const grown = grow(view, i, text, total, true); - expect(grown.merged).toBe(false); - total = grown.total; - } - expect(view.batching).toBe(true); - parents = true; - // Nothing was built since the last try: the batch waits. - const waiting = grow(view, 260, text, total, false); - expect(waiting).toEqual({ merged: false, tried: false, total: viewBytes(view.lines, text) }); - expect(grow(view, 261, text, waiting.total, true).merged).toBe(true); - expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW); - expect(view.batching).toBe(false); -}); - -test("compaction context stops before the target and at the first unbuilt line", () => { - const text = (line: Line) => (key(line) === "0:5" ? undefined : "t"); - const view: Line[] = [ + const context = (line: Line) => (key(line) === "0:5" ? undefined : "t"); + const zoomed: Line[] = [ [2, 0], [0, 4], [0, 5], [0, 6], ]; - expect(before(view, 4, text)).toEqual([[2, 0]]); - expect(before(view, 7, text)).toEqual([ + expect(before(zoomed, 4, context)).toEqual([[2, 0]]); + expect(before(zoomed, 7, context)).toEqual([ [2, 0], [0, 4], ]); }); -test("view blocks keep whole 4-line blocks stable as lines are appended", () => { - const a = blocks(["1", "2", "3", "4", "5", "6"]); - expect(a.whole).toEqual(["\n1\n2\n3\n4\n"]); - expect(a.tail).toBe("5\n6\n"); - const b = blocks(["1", "2", "3", "4", "5", "6", "7", "8", "9"]); - expect(b.whole[0]).toBe(a.whole[0]!); - expect(b.tail).toBe("9\n"); - expect(blocks([])).toEqual({ whole: [], tail: "\n" }); -}); - -test("lines render with labels and without newlines", () => { - expect(render([3, 5], "a\nb")).toBe("40+8|a b"); - expect(render([0, 7], undefined)).toBe("7+1|(not summarized yet: zoom it)"); -}); - -test("long tool outputs are clipped; other long texts are split", () => { - const long = "a".repeat(20_000) + "b".repeat(20_000); - const [echo] = pieces("echo", long); - expect(echo!.startsWith("a".repeat(15_000))).toBe(true); - expect(echo!.endsWith("b".repeat(15_000))).toBe(true); - expect(echo!).toContain("10000 characters clipped"); - expect(pieces("agent", long).join("")).toBe(long); - expect(pieces("agent", long)).toHaveLength(2); - expect(pieces("user", "short")).toEqual(["short"]); +test("view is a sawtooth: grows past 128 KB, one batch drops it to 64 KB, and a short batch continues", () => { + { + const line = "y".repeat(500); + const text = () => line; + const view: View = { lines: [], batching: false }; + let peak = 0; + let batches = 0; + let total = 0; + for (let i = 0; i < 2000; i++) { + const size = viewBytes(view.lines, text); + expect(total).toBe(size); + peak = Math.max(peak, size); + const grown = grow(view, i, text, total, true); + total = grown.total; + if (grown.merged) { + batches++; + expect(size).toBeLessThanOrEqual(VIEW_HIGH); + expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW); + } else expect(view.lines.at(-1)).toEqual([0, i]); + } + expect(peak).toBeGreaterThan(VIEW_LOW); + expect(peak).toBeLessThanOrEqual(VIEW_HIGH + 600); + expect(batches).toBeGreaterThan(1); + // Lines stay contiguous and cover every message. + let next = 0; + for (const [l, i] of view.lines) { + expect(i * 2 ** l).toBe(next); + next += 2 ** l; + } + expect(next).toBe(2000); + } + { + let parents = false; + const text = (line: Line) => (line[0] === 0 || parents ? "z".repeat(500) : undefined); + const view: View = { lines: [], batching: false }; + let total = 0; + for (let i = 0; i < 260; i++) { + const grown = grow(view, i, text, total, true); + expect(grown.merged).toBe(false); + total = grown.total; + } + expect(view.batching).toBe(true); + parents = true; + // Nothing was built since the last try: the batch waits. + const waiting = grow(view, 260, text, total, false); + expect(waiting).toEqual({ + merged: false, + tried: false, + total: viewBytes(view.lines, text), + }); + expect(grow(view, 261, text, waiting.total, true).merged).toBe(true); + expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW); + expect(view.batching).toBe(false); + } }); test("tasks are verbatim and Too long cuts at 512 bytes", () => { @@ -272,35 +228,6 @@ test("queue resumes unbuilt messages and ready merges from disk", async () => { expect(started).toEqual(["0:3", "1:0", "1:1", "2:0"]); }); -test("a long unbuilt view grows in linear time and pending batches wait for builds", () => { - // Regression: enabling optchat in a long session re-measured and re-merged the whole view per message. - const text = () => undefined; - const view: View = { lines: [], batching: false }; - let total = 0; - let built = true; - const started = performance.now(); - for (let i = 0; i < 20_000; i++) { - const grown = grow(view, i, text, total, built); - total = grown.total; - if (grown.tried) built = false; - } - expect(total).toBe(viewBytes(view.lines, text)); - expect(view.batching).toBe(true); - expect(performance.now() - started).toBeLessThan(2_000); -}); - -test("contains finds exactly the view's lines", () => { - const view: Line[] = [ - [2, 0], - [1, 2], - [0, 6], - ]; - expect(contains(view, [1, 2])).toBe(true); - expect(contains(view, [0, 6])).toBe(true); - expect(contains(view, [0, 4])).toBe(false); - expect(contains(view, [0, 7])).toBe(false); -}); - test("a merge is queued once when both halves finish in the same tick", async () => { // Regression: short leaves build synchronously, so both finishes saw the parent unbuilt. const tree = new Set(); @@ -319,19 +246,3 @@ test("a merge is queued once when both halves finish in the same tick", async () await Bun.sleep(30); expect(starts).toEqual(["0:0", "0:1", "1:0"]); }); - -test("settle stops waiting once failures leave nothing running", async () => { - // Regression: it waited the full timeout though failures retry only at the next message. - const queue = new Queue( - () => false, - async () => { - await Bun.sleep(5); - return false; - }, - ); - queue.add(0); - const started = Date.now(); - expect(await queue.settle(1, 2_000)).toBe(false); - expect(Date.now() - started).toBeLessThan(500); - expect(queue.running).toBe(0); -}); diff --git a/tests/optchat-memory.test.ts b/tests/optchat-memory.test.ts index 5f297c4..eba4c0c 100644 --- a/tests/optchat-memory.test.ts +++ b/tests/optchat-memory.test.ts @@ -55,21 +55,6 @@ test("logs append-only, dedupe by source, split long texts, and reload with tree await again.close(); }); -test("messages logged after the saved view are appended to it at load", async () => { - const directory = await fixture(); - const memory = await Memory.open(directory); - memory.log("user", "a", "s:0"); - memory.saveView(); - memory.log("user", "b", "s:1"); - await memory.close(); - const again = await Memory.open(directory); - expect(again.view.lines).toEqual([ - [0, 0], - [0, 1], - ]); - await again.close(); -}); - test("one writer: the lock rejects a live holder and replaces a dead one", async () => { const directory = await fixture(); const memory = await Memory.open(directory); diff --git a/tests/optchat.test.ts b/tests/optchat.test.ts index 7ca4ff4..a71d551 100644 --- a/tests/optchat.test.ts +++ b/tests/optchat.test.ts @@ -91,7 +91,10 @@ async function host() { callback: (editor: { add: (definition: { execute: typeof command }) => void; }) => void, - ) => callback({ add: (definition) => (command = definition.execute) }), + ) => + callback({ + add: (definition) => (command = definition.execute), + }), }, session: { hook: async (name: string, callback: (event: Event) => Promise | void) => { @@ -106,7 +109,11 @@ async function host() { : { id: sessionID, agent: "build" }, create: async (input: { parentID: string; model: object }) => { expect(input.parentID).toBe("ses_root"); - expect(input.model).toEqual({ providerID: "acme", id: "cheap", variant: "xhigh" }); + expect(input.model).toEqual({ + providerID: "acme", + id: "cheap", + variant: "xhigh", + }); return { id: "ses_worker" }; }, generate: async ({ sessionID, prompt }: { sessionID: string; prompt: string }) => { @@ -116,7 +123,10 @@ async function host() { agent: "build", system: [{ type: "text", text: "host system" }], messages: [ - new Entry({ role: "user", content: [{ type: "text", text: prompt }] }), + new Entry({ + role: "user", + content: [{ type: "text", text: prompt }], + }), ], tools: {}, }; @@ -137,7 +147,11 @@ async function host() { sessionID, agent: "build", system: [ - { type: "text", text: "host one", cache: { type: "ephemeral" } }, + { + type: "text", + text: "host one", + cache: { type: "ephemeral" }, + }, { type: "text", text: "host two" }, ], messages: messages.map((message) => new Entry(message)), @@ -187,7 +201,10 @@ test("optchat is off by default, refuses child sessions, and hides its tools whe test("turns see the view and the new message; compactions retry Too long and build the tree", async () => { const h = await host(); - await h.command()({ sessionID: "ses_root", prompt: { text: "/optchat on" } }); + await h.command()({ + sessionID: "ses_root", + prompt: { text: "/optchat on" }, + }); expect(h.notices.at(-1)).toStartWith("[optchat] on. 0 messages"); const long = "please remember: " + "detail ".repeat(100); h.replies.push("x".repeat(700), "y".repeat(600), "z".repeat(400)); @@ -204,7 +221,10 @@ test("turns see the view and the new message; compactions retry Too long and bui expect(first.messages[1]!.content[0]!.text).toBe(long); // System start and end, and the request's end; no whole view block yet. expect(hints(first)).toBe(3); - expect(first.messages[1]!.content[0]!.cache).toEqual({ type: "ephemeral", ttlSeconds: 300 }); + expect(first.messages[1]!.content[0]!.cache).toEqual({ + type: "ephemeral", + ttlSeconds: 300, + }); await settle(); // The compaction ran in the worker with the same system prompt, retried twice, and kept the shortest line. @@ -226,7 +246,12 @@ test("turns see the view and the new message; compactions retry Too long and bui role: "assistant", content: [ { type: "reasoning", text: "never logged" }, - { type: "tool-call", id: "c1", name: "read", input: { path: "a" } }, + { + type: "tool-call", + id: "c1", + name: "read", + input: { path: "a" }, + }, ], }, { @@ -241,8 +266,16 @@ test("turns see the view and the new message; compactions retry Too long and bui }, ], }, - { id: "m4", role: "assistant", content: [{ type: "text", text: "Read a." }] }, - { id: "m5", role: "user", content: [{ type: "text", text: "and now?" }] }, + { + id: "m4", + role: "assistant", + content: [{ type: "text", text: "Read a." }], + }, + { + id: "m5", + role: "user", + content: [{ type: "text", text: "and now?" }], + }, ]); // Messages before the new one are summarized before the turn; short ones are their own lines. expect(second.messages.map((message) => message.role)).toEqual(["user", "user"]); @@ -330,7 +363,12 @@ test("dropped reasoning parts and tool messages without ids do not re-log or ski role: "assistant", content: [ { type: "reasoning", text: "" }, - { type: "tool-call", id: "c1", name: "read", input: { path: "a" } }, + { + type: "tool-call", + id: "c1", + name: "read", + input: { path: "a" }, + }, ], }, { @@ -360,9 +398,17 @@ test("dropped reasoning parts and tool messages without ids do not re-log or ski content: message.content.filter((part) => part.type !== "reasoning"), })); await h.turn("ses_root", [ - { id: "m0", role: "system", content: [{ type: "text", text: "Instructions updated" }] }, + { + id: "m0", + role: "system", + content: [{ type: "text", text: "Instructions updated" }], + }, ...after, - { id: "m5", role: "assistant", content: [{ type: "text", text: "ok" }] }, + { + id: "m5", + role: "assistant", + content: [{ type: "text", text: "ok" }], + }, { id: "m6", role: "user", content: [{ type: "text", text: "again" }] }, ]); await settle(); @@ -378,85 +424,23 @@ test("dropped reasoning parts and tool messages without ids do not re-log or ski ]); }); -test("instruction updates before the turn stay after the view", async () => { - // Regression: system-role messages before the turn start were dropped. - const h = await host(); - await h.command()({ sessionID: "ses_root", prompt: { text: "on" } }); - const event = await h.turn("ses_root", [ - { id: "m1", role: "user", content: [{ type: "text", text: "hi" }] }, - { id: "m2", role: "assistant", content: [{ type: "text", text: "hello" }] }, - { id: "m3", role: "system", content: [{ type: "text", text: "Instructions updated" }] }, - { id: "m4", role: "user", content: [{ type: "text", text: "next" }] }, - ]); - expect(event.messages.map((message) => message.role)).toEqual(["user", "system", "user"]); - expect(event.messages[1]!.content[0]!.text).toBe("Instructions updated"); - if (h.cleanup) await h.cleanup(); +test("without compactionModel, optchat registers nothing", async () => { + const options = { compactionModel: "", dataDir: "" }; + const registered: string[] = []; + const record = (name: string) => async () => { + registered.push(name); + return { dispose: async () => {} }; + }; + const context = { + options, + tool: { transform: record("tool") }, + command: { transform: record("command") }, + session: { hook: record("hook") }, + }; + // SAFETY: setup must return before touching any host service; a call is recorded and fails the test. + const setup = optchat.setup as ( + context: Pick, + ) => ReturnType; + expect(await setup(context)).toBeUndefined(); + expect(registered).toEqual([]); }); - -test("checkpoints are not logged, provider-executed calls end a turn, and empty replies stop retries", async () => { - const h = await host(); - await h.command()({ sessionID: "ses_root", prompt: { text: "on" } }); - h.replies.push(""); - const event = await h.turn("ses_root", [ - { - id: "m1", - role: "user", - content: [ - { type: "text", text: "old" }, - ], - }, - { - id: "m2", - role: "assistant", - content: [ - { - type: "tool-call", - id: "w1", - name: "web_search", - input: {}, - providerExecuted: true, - }, - { type: "text", text: "x".repeat(600) }, - ], - }, - { id: "m3", role: "user", content: [{ type: "text", text: "next" }] }, - ]); - // The turn is the new message; the provider-executed call did not keep the old turn open. - expect(event.messages.map((message) => message.content[0]!.text)).toEqual([ - "\n0+1|tool: web_search {}\n1+1|(not summarized yet: zoom it)\n", - "next", - ]); - await settle(); - // The empty reply ended its compaction without a "Too long" retry; the failure is retried at - // the next message, as a fresh task. - expect(h.generated.map((request) => request.messages.length)).toEqual([2, 2]); - if (h.cleanup) await h.cleanup(); - expect(await logged(h.directory)).toEqual([ - "tool: web_search {}", - `agent: ${"x".repeat(600)}`, - "user: next", - ]); -}); - -test.each([{}, { compactionModel: "", dataDir: "" }])( - "without compactionModel (%j), optchat registers nothing", - async (options) => { - const registered: string[] = []; - const record = (name: string) => async () => { - registered.push(name); - return { dispose: async () => {} }; - }; - const context = { - options, - tool: { transform: record("tool") }, - command: { transform: record("command") }, - session: { hook: record("hook") }, - }; - // SAFETY: setup must return before touching any host service; a call is recorded and fails the test. - const setup = optchat.setup as ( - context: Pick, - ) => ReturnType; - expect(await setup(context)).toBeUndefined(); - expect(registered).toEqual([]); - }, -); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 8f9ec10..4e651b5 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -229,22 +229,6 @@ async function host( }; } -test("Agents is independently loadable with no quota, storage, MCP, or custom tools", async () => { - expect(plugin.id).toBe("stanp-opencode.agents"); - const h = await host(); - expect(h.agents.has("coordinate")).toBe(true); - expect(h.agents.has("multi-work")).toBe(false); - expect(h.agents.get("build")?.mode).toBe("primary"); - expect(h.skills.get("anti-overengineering")?.content).toContain("correctness"); -}); - -test("preserves an existing user anti-overengineering skill", async () => { - const first = await host(); - const original = { ...first.skills.get("anti-overengineering")!, content: "user definition" }; - const h = await host(models, { agents }, original); - expect(h.skills.get("anti-overengineering")).toEqual(original); -}); - test("worker availability, deep fallback, and model refresh remain provider aware", async () => { const h = await host(); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); @@ -267,19 +251,7 @@ test("without agents options or with the empty skeleton nothing is registered", } }); -test("empty deep pair disables deep; partially filled required assignments fail", async () => { - const h = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } }); - expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); - expect(h.agents.has("deep-lo")).toBe(false); - await expect(host(models, { agents: { ...agents, "good-hi": "" } })).rejects.toThrow( - "agents good-hi is required", - ); - await expect(host(models, { agents: { "deep-lo": agents["deep-lo"] } })).rejects.toThrow( - "agents fast-lo is required", - ); -}); - -test("assignments are provider-qualified with optional deep pair and variant", async () => { +test("assignments are provider-qualified with optional deep pair and variant; partial ones fail", async () => { const shallow = Object.fromEntries( Object.entries(agents).filter(([name]) => !name.startsWith("deep-")), ); @@ -296,47 +268,15 @@ test("assignments are provider-qualified with optional deep pair and variant", a await expect( host(models, { agents: { ...agents, "fast-lo": "unqualified#low" } }), ).rejects.toThrow("agents fast-lo must be provider/model[#variant]"); -}); - -test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => { - const h = await host(); - const rules = [ - { action: "*", resource: "*", effect: "allow" as const }, - { action: "read", resource: "secret", effect: "deny" as const }, - ]; - await h.configure(rules); - expect(await h.permissions()).toEqual(rules); - expect(h.agents.get("fast-lo")?.permissions).toEqual([ - ...rules, - { action: "subagent", resource: "*", effect: "deny" }, - ]); - expect(h.agents.get("coordinate")?.permissions).toContainEqual(rules[1]); - expect(h.agents.get("coordinate")?.permissions.some((rule) => rule.action === "shell")).toBe( - false, + const empty = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } }); + expect(String(empty.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); + expect(empty.agents.has("deep-lo")).toBe(false); + await expect(host(models, { agents: { ...agents, "good-hi": "" } })).rejects.toThrow( + "agents good-hi is required", + ); + await expect(host(models, { agents: { "deep-lo": agents["deep-lo"] } })).rejects.toThrow( + "agents fast-lo is required", ); -}); - -test("available Codegraph tools inherit permission rules without changing MCP configuration", async () => { - const h = await host(); - await h.availableTools(["codegraph_query", "docs_search"]); - await h.configure([ - { action: "*", resource: "*", effect: "allow" }, - { action: "code*", resource: "*", effect: "ask" }, - ]); - expect(h.agents.get("coordinate")?.permissions).toContainEqual({ - action: "codegraph_query", - resource: "*", - effect: "ask", - }); - expect( - h.agents.get("coordinate")?.permissions.some((rule) => rule.action === "docs_search"), - ).toBe(false); - await h.availableTools([]); - expect( - h.agents - .get("coordinate") - ?.permissions.some((rule) => rule.action.startsWith("codegraph_")), - ).toBe(false); }); test("native tool and command paths retain core delegation restrictions", async () => { @@ -357,30 +297,3 @@ test("native tool and command paths retain core delegation restrictions", async .parse((await h.request("coordinate")).subagent?.input.properties).agent, ).toEqual({ type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }); }); - -test("foreign-location model events do not refresh this plugin instance", async () => { - const h = await host(); - await h.models([], { directory: "/elsewhere" }); - expect(h.agents.has("coordinate")).toBe(true); -}); - -test("reasoning uses advertised variants and falls back to request settings", async () => { - const h = await host(); - expect(String(h.agents.get("fast-lo")?.model?.variant)).toBe("low"); - expect(h.agents.get("fast-lo")?.request?.settings).toEqual({}); - const noVariants = models.map((model) => ({ ...model, variants: [] })); - const fallback = await host(noVariants); - expect(fallback.agents.get("fast-lo")?.model?.variant).toBeUndefined(); - expect(fallback.agents.get("fast-lo")?.request?.settings).toEqual({ reasoningEffort: "low" }); - expect(fallback.agents.get("coordinate")?.request?.settings).toEqual({ - reasoningEffort: "low", - }); -}); - -test("disabled catalog entries do not authorize worker availability", async () => { - const h = await host( - models.map((model) => ({ ...model, enabled: model.id !== "gpt-6-astra" })), - ); - expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); - expect(h.agents.has("deep-lo")).toBe(false); -}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 4b4e9db..e35fe92 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -1,11 +1,7 @@ import { expect, test } from "bun:test"; import type { Plugin } from "@opencode/plugin"; -import type { Context } from "@opencode/plugin/effect/plugin"; -import { Effect } from "effect"; import quota from "../src/quota-plugin.js"; -import multiWork from "../src/multi-work.js"; import { QuotaRpc } from "../src/quota-rpc.js"; -import { AgentsRpc } from "../src/agents-rpc.js"; test("Quota loads independently and retains its RPC ID and nullable status", async () => { let controller: ReadableStreamDefaultController; @@ -65,20 +61,3 @@ test("Quota loads independently and retains its RPC ID and nullable status", asy if (cleanup) await cleanup(); expect(disposed).toBe(true); }); - -test("Multi-work rejects missing Agents before any Herdr operations or registrations", async () => { - const context = { - rpc: (definition: typeof AgentsRpc) => { - expect(definition.id).toBe("stanp-opencode.agents"); - return { permissions: () => Effect.fail(new Error("RPC not registered")) }; - }, - }; - // SAFETY: Missing Agents fails at the first RPC call; no other host service is accessed. - const activate = multiWork.effect as ( - context: Pick, - ) => ReturnType; - const result = await Effect.runPromiseExit( - Effect.scoped(activate({ ...context, options: {} })), - ); - expect(String(result)).toContain("Multi-work requires Agents loaded before it"); -}); diff --git a/tests/quota.test.ts b/tests/quota.test.ts index adfef4a..0a05ada 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -1,7 +1,6 @@ import { expect, test } from "bun:test"; import type { Schema } from "effect"; import { normalizeQuota, QuotaTracker } from "../src/quota.js"; -import { formatQuotaWindow, loadQuotaStatus } from "../src/tui.js"; const plus = { plan_type: "plus", @@ -24,11 +23,35 @@ const plus = { }, }; -test("normalizes representative Plus and Pro payloads and hides absent windows", () => { - expect(normalizeQuota(plus, 123)).toEqual({ - plan: "plus", - fetchedAt: 123, - windows: [ +test("normalizes Plus and Pro payloads, hiding absent windows and excluded Spark and reserve limits", () => { + { + expect(normalizeQuota(plus, 123)).toEqual({ + plan: "plus", + fetchedAt: 123, + windows: [ + { + id: "codex:primary", + label: null, + seconds: 18_000, + used: 12.5, + resetsAt: 2_000_000_000_000, + }, + { + id: "codex:secondary", + label: null, + seconds: 604_800, + used: 41, + resetsAt: 2_000_500_000_000, + }, + ], + }); + expect(normalizeQuota({ ...plus, plan_type: "pro" }, 123)?.windows).toHaveLength(2); + expect( + normalizeQuota({ + plan_type: "pro", + rate_limit: { primary_window: plus.rate_limit.primary_window }, + })?.windows, + ).toEqual([ { id: "codex:primary", label: null, @@ -36,124 +59,54 @@ test("normalizes representative Plus and Pro payloads and hides absent windows", used: 12.5, resetsAt: 2_000_000_000_000, }, - { - id: "codex:secondary", - label: null, - seconds: 604_800, - used: 41, - resetsAt: 2_000_500_000_000, - }, - ], - }); - expect(normalizeQuota({ ...plus, plan_type: "pro" }, 123)?.windows).toHaveLength(2); - expect( - normalizeQuota({ - plan_type: "pro", - rate_limit: { primary_window: plus.rate_limit.primary_window }, - })?.windows, - ).toEqual([ - { - id: "codex:primary", - label: null, - seconds: 18_000, - used: 12.5, - resetsAt: 2_000_000_000_000, - }, - ]); - expect(normalizeQuota({ plan_type: "plus", rate_limit: null })).toBeUndefined(); -}); - -test("normalizes additional named limits with stable IDs distinct from equal durations", () => { - const normalized = normalizeQuota({ - ...plus, - additional_rate_limits: [ - { - limit_name: "GPT-5.6 Luna", - metered_feature: "codex_luna", - rate_limit: { primary_window: plus.rate_limit.primary_window }, - }, - ], - }); - expect(normalized?.windows.map((window) => window.id)).toEqual([ - "codex:primary", - "codex:secondary", - "codex_luna:primary", - ]); - expect(normalized?.windows[2]?.label).toBe("GPT-5.6 Luna"); - expect( - normalizeQuota({ - plan_type: "pro", - rate_limit: null, + ]); + expect(normalizeQuota({ plan_type: "plus", rate_limit: null })).toBeUndefined(); + } + { + const normalized = normalizeQuota({ + ...plus, additional_rate_limits: [ + { + limit_name: "gpt 5_3 codex spark", + metered_feature: "CODEX-BENGALFOX", + rate_limit: { + primary_window: { used_percent: "invalid" }, + }, + }, + { + limit_name: "Luna Reserve", + metered_feature: "codex_luna", + rate_limit: { primary_window: { used_percent: "invalid" } }, + }, + { + limit_name: "Luna overflow", + metered_feature: "codex_luna_reserve", + rate_limit: { primary_window: { used_percent: "invalid" } }, + }, { limit_name: "GPT-5.6 Luna", metered_feature: "codex_luna", rate_limit: { primary_window: plus.rate_limit.primary_window }, }, - ], - })?.windows.map((window) => window.id), - ).toEqual(["codex_luna:primary"]); -}); - -test("ignores Spark and representative reserve limits while preserving other quotas", () => { - const normalized = normalizeQuota({ - ...plus, - additional_rate_limits: [ - { - limit_name: "gpt 5_3 codex spark", - metered_feature: "CODEX-BENGALFOX", - rate_limit: { - primary_window: { used_percent: "invalid" }, - }, - }, - { - limit_name: "Luna Reserve", - metered_feature: "codex_luna", - rate_limit: { primary_window: { used_percent: "invalid" } }, - }, - { - limit_name: "Luna overflow", - metered_feature: "codex_luna_reserve", - rate_limit: { primary_window: { used_percent: "invalid" } }, - }, - { - limit_name: "GPT-5.6 Luna", - metered_feature: "codex_luna", - rate_limit: { primary_window: plus.rate_limit.primary_window }, - }, - { - limit_name: "Preserved capacity", - metered_feature: "codex_preserved", - rate_limit: { primary_window: plus.rate_limit.primary_window }, - }, - ], - }); - - expect(normalized?.windows.map((window) => window.id)).toEqual([ - "codex:primary", - "codex:secondary", - "codex_luna:primary", - "codex_preserved:primary", - ]); - expect(normalized?.windows.slice(2).map((window) => window.label)).toEqual([ - "GPT-5.6 Luna", - "Preserved capacity", - ]); -}); - -test("returns no quota for a Spark-only payload", () => { - expect( - normalizeQuota({ - plan_type: "pro", - additional_rate_limits: [ { - limit_name: "GPT-5.3-Codex-Spark", - metered_feature: "codex_bengalfox", + limit_name: "Preserved capacity", + metered_feature: "codex_preserved", rate_limit: { primary_window: plus.rate_limit.primary_window }, }, ], - }), - ).toBeUndefined(); + }); + + expect(normalized?.windows.map((window) => window.id)).toEqual([ + "codex:primary", + "codex:secondary", + "codex_luna:primary", + "codex_preserved:primary", + ]); + expect(normalized?.windows.slice(2).map((window) => window.label)).toEqual([ + "GPT-5.6 Luna", + "Preserved capacity", + ]); + } }); function fixture( @@ -242,72 +195,6 @@ function fixture( }; } -async function accountFingerprint(id: string) { - const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`account:${id}`)); - return Buffer.from(bytes).toString("hex"); -} - -test("reads cached snapshots and removes excluded windows", async () => { - const cached = { - account: await accountFingerprint("account-one"), - latest: { - plan: "pro", - fetchedAt: 123, - windows: [ - { - id: "codex:primary", - label: null, - seconds: 18_000, - used: 12.5, - resetsAt: 2_000_000_000_000, - }, - { - id: "CODEX-BENGALFOX:primary", - label: "GPT 5.3 Codex Spark", - seconds: 18_000, - used: 80, - resetsAt: 2_000_000_000_000, - }, - { - id: "codex_luna_reserve:primary", - label: "Luna Reserve", - seconds: 18_000, - used: 60, - resetsAt: 2_000_000_000_000, - }, - { - id: "codex_luna:primary", - label: "GPT-5.6 Luna", - seconds: 18_000, - used: 20, - resetsAt: 2_000_000_000_000, - }, - ], - }, - }; - const result = fixture( - { - type: "oauth", - access: "opaque", - refresh: "refresh", - metadata: { accountID: "account-one" }, - }, - [new Error("offline")], - 1_000_000, - [Promise.resolve(cached)], - ); - - await result.tracker.init(); - const status = result.tracker.status(); - expect(status?.windows.map((window) => window.id)).toEqual([ - "codex:primary", - "codex_luna:primary", - ]); - expect(status?.windows[0]).not.toHaveProperty("sessionConsumed"); - expect(result.calls()).toBe(1); - await result.tracker.dispose(); -}); - test("activates only for OAuth and refreshes quota at initialization and request start", async () => { const key = fixture({ type: "key", key: "secret" }, [plus]); await key.tracker.init(); @@ -347,124 +234,6 @@ test("activates only for OAuth and refreshes quota at initialization and request await oauth.tracker.dispose(); }); -test("polls, tolerates errors, and clears on logout", async () => { - const reset = { - ...plus, - rate_limit: { - ...plus.rate_limit, - primary_window: { - ...plus.rate_limit.primary_window, - used_percent: 2, - reset_at: 2_000_100_000, - }, - }, - }; - const result = fixture( - { - type: "oauth", - access: "opaque", - refresh: "refresh", - metadata: { accountID: "account-one" }, - }, - [plus, new Error("offline"), reset, reset, reset, reset], - 10, - ); - await result.tracker.init(); - await result.tracker.start("root"); - await Bun.sleep(35); - const status = result.tracker.status(); - expect(result.calls()).toBeGreaterThanOrEqual(3); - expect(status?.windows[0]?.remaining).toBe(98); - result.logout(); - await result.tracker.authChanged(true); - expect(result.tracker.status()).toBeUndefined(); - const calls = result.calls(); - await result.tracker.dispose(); - await Bun.sleep(20); - expect(result.calls()).toBe(calls); -}); - -test("sidebar text places reset timing beside latest remaining quota", () => { - expect( - formatQuotaWindow( - { - seconds: 604_800, - id: "codex:secondary", - label: null, - used: 25, - remaining: 75, - resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000, - }, - 100_000, - ), - ).toBe("7d: 75% · 2d 5h"); -}); - -test("sidebar reset timing uses compact future units and handles boundaries", () => { - const now = 1_000_000; - const format = (resetIn: number) => - formatQuotaWindow( - { - seconds: 18_000, - id: "codex:primary", - label: null, - used: 25, - remaining: 75, - resetsAt: now + resetIn, - }, - now, - ); - - expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% · 2h 15m"); - expect(format(3_600_000)).toBe("5h: 75% · 1h"); - expect(format(15 * 60_000)).toBe("5h: 75% · 15m"); - expect(format(60_000)).toBe("5h: 75% · 1m"); - expect(format(59_999)).toBe("5h: 75% · <1m"); -}); - -test("sidebar reset timing preserves labels and distinguishes stale and unavailable timestamps", () => { - const window = { - seconds: 18_000, - id: "codex:primary", - label: "GPT-5.6 Luna", - used: 99.95, - remaining: 0.05, - }; - expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe( - "GPT-5.6 Luna 5h: <0.1% · due", - ); - expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe( - "GPT-5.6 Luna 5h: <0.1% · unavailable", - ); -}); - -test("request completion refreshes once across terminal and idle events", async () => { - const used = (percent: number) => ({ - ...plus, - rate_limit: { - ...plus.rate_limit, - primary_window: { ...plus.rate_limit.primary_window, used_percent: percent }, - }, - }); - const result = fixture( - { - type: "oauth", - access: "opaque", - refresh: "refresh", - metadata: { accountID: "account-one" }, - }, - [used(10), used(11), used(30), new Error("temporary"), used(30), used(33), used(35)], - ); - await result.tracker.init(); - await result.tracker.start("root"); - await result.tracker.complete("root"); - expect(result.tracker.status()?.windows[0]?.remaining).toBe(70); - await result.tracker.complete("root"); - expect(result.calls()).toBe(3); - expect(result.tracker.status()?.windows[0]?.remaining).toBe(70); - await result.tracker.dispose(); -}); - test("an auth switch detaches an in-flight old-account response and immediately fetches the new account", async () => { const pending = Promise.withResolvers(); const second = { @@ -507,44 +276,3 @@ test("an auth switch detaches an in-flight old-account response and immediately expect(result.tracker.status()?.windows[0]).not.toHaveProperty("sessionConsumed"); await result.tracker.dispose(); }); - -test("an auth switch during an old account storage read cannot restore stale state or polling", async () => { - const pending = Promise.withResolvers(); - const second = { - ...plus, - account_id: "account-two", - rate_limit: { - ...plus.rate_limit, - primary_window: { ...plus.rate_limit.primary_window, used_percent: 64 }, - }, - }; - const result = fixture( - { type: "oauth", access: "old", refresh: "old", metadata: { accountID: "account-one" } }, - [second], - 1_000_000, - [pending.promise], - ); - const old = result.tracker.init(); - while (result.storageCalls() === 0) await Bun.sleep(1); - result.switch({ - type: "oauth", - access: "new", - refresh: "new", - metadata: { accountID: "account-two" }, - }); - await result.tracker.authChanged(true); - expect(result.calls()).toBe(1); - pending.resolve(undefined); - await old; - expect(result.tracker.status()?.windows[0]?.used).toBe(64); - expect(result.headers[0]?.get("chatgpt-account-id")).toBe("account-two"); - await result.tracker.dispose(); -}); - -test("TUI quota loading converts RPC failures to an empty render state", async () => { - expect( - await loadQuotaStatus(async () => { - throw new Error("disconnected"); - }), - ).toBeNull(); -}); diff --git a/tests/release.test.ts b/tests/release.test.ts deleted file mode 100644 index 7eddfcb..0000000 --- a/tests/release.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { z } from "zod"; - -const workflow = z - .object({ - jobs: z.object({ - release: z.object({ - steps: z.array(z.object({ name: z.string(), run: z.string().optional() })), - }), - }), - }) - .parse( - Bun.YAML.parse( - await Bun.file(new URL("../.gitea/workflows/release.yml", import.meta.url)).text(), - ), - ); -const script = workflow.jobs.release.steps.find( - (step) => step.name === "Publish Gitea release", -)!.run!; -const base = "/api/v1/repos/test/project/releases"; -const tag = "release/v1+rc#1"; -const assets = ["stanp-opencode.tgz", "stanp-opencode.tgz.sha256"]; - -async function publish(status: number) { - const directory = await mkdtemp(join(tmpdir(), "stanp-release-")); - const requests: { method: string; path: string; body: unknown }[] = []; - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(request) { - const path = new URL(request.url).pathname; - if (request.headers.get("content-type")?.startsWith("multipart/form-data")) { - const attachment = (await request.formData()).get("attachment"); - if (!(attachment instanceof File)) { - return new Response("Missing attachment", { status: 400 }); - } - requests.push({ - method: request.method, - path, - body: { name: attachment.name, content: await attachment.text() }, - }); - } else if (request.method === "POST" || request.method === "PATCH") { - requests.push({ method: request.method, path, body: await request.json() }); - } else { - requests.push({ method: request.method, path, body: null }); - } - if (request.method === "POST" && path === base) { - return Response.json( - status === 201 - ? { id: 42, assets: [] } - : { message: "release creation denied" }, - { status }, - ); - } - if (request.method === "GET" && path === `${base}/tags/${encodeURIComponent(tag)}`) { - return Response.json({ - id: 42, - name: "Keep this title", - body: "Keep these notes", - assets: [ - { id: 10, name: assets[0] }, - { id: 11, name: "stanp-opencode.tgz.sig" }, - { id: 12, name: assets[1] }, - ], - }); - } - if (request.method === "DELETE" && /\/42\/assets\/(10|12)$/.test(path)) { - return new Response(null, { status: 204 }); - } - if (request.method === "POST" && path === `${base}/42/assets`) { - return Response.json({ id: 20 }, { status: 201 }); - } - if (request.method === "PATCH" && path === `${base}/42`) { - return Response.json({ id: 42, draft: false }); - } - return Response.json({ message: "unexpected request" }, { status: 500 }); - }, - }); - try { - await Promise.all( - assets.map((asset) => writeFile(join(directory, asset), `contents of ${asset}`)), - ); - const process = Bun.spawn(["bash", "-c", script], { - cwd: directory, - env: { - ...Bun.env, - GITEA_SERVER_URL: server.url.origin, - GITEA_REPOSITORY: "test/project", - GITEA_TAG: tag, - GITEA_COMMIT: "abc123", - GITEA_TOKEN: "test-token", - }, - stdout: "pipe", - stderr: "pipe", - timeout: 10_000, - }); - const [exitCode, stdout, stderr] = await Promise.all([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), - ]); - return { exitCode, output: stdout + stderr, requests }; - } finally { - await server.stop(true); - await rm(directory, { recursive: true, force: true }); - } -} - -for (const status of [201, 409]) { - test(`publishes both assets after release creation returns ${status}`, async () => { - const result = await publish(status); - expect(result.exitCode).toBe(0); - expect(result.requests.filter(({ method }) => method !== "DELETE")).toEqual([ - { - method: "POST", - path: base, - body: { tag_name: tag, name: tag, target_commitish: "abc123", draft: true }, - }, - ...(status === 409 - ? [{ method: "GET", path: `${base}/tags/${encodeURIComponent(tag)}`, body: null }] - : []), - ...assets.map((asset) => ({ - method: "POST", - path: `${base}/42/assets`, - body: { name: asset, content: `contents of ${asset}` }, - })), - { method: "PATCH", path: `${base}/42`, body: { draft: false } }, - ]); - const deletions = result.requests.filter(({ method }) => method === "DELETE"); - expect(deletions).toEqual( - status === 409 - ? [10, 12].map((id) => ({ - method: "DELETE", - path: `${base}/42/assets/${id}`, - body: null, - })) - : [], - ); - const uploads = result.requests.filter( - ({ method, path }) => method === "POST" && path === `${base}/42/assets`, - ); - for (const [index, deletion] of deletions.entries()) { - expect(result.requests.indexOf(deletion)).toBeLessThan( - result.requests.indexOf(uploads[index]!), - ); - } - }, 15_000); -} - -test("non-conflict creation failure stops publishing and shows the response body", async () => { - const result = await publish(403); - expect(result.exitCode).not.toBe(0); - expect(result.output).toContain("release creation denied"); - expect(result.requests.map(({ method, path }) => ({ method, path }))).toEqual([ - { method: "POST", path: base }, - ]); -}, 15_000); diff --git a/tests/tui.test.ts b/tests/tui.test.ts index f1458ab..efc796f 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -2,7 +2,6 @@ import { expect, test } from "bun:test"; import type { Plugin } from "@opencode/plugin/tui"; import type { Route, SlotMap } from "@opencode/plugin/tui/context"; import { testRender, type JSX } from "@opentui/solid"; -import { jsx } from "@opentui/solid/jsx-runtime"; import { createSignal } from "solid-js"; import { build } from "../scripts/build.js"; import type { QuotaStatus } from "../src/quota.js"; @@ -160,66 +159,3 @@ test("built quota slot stays reactive across pending, empty, failure, update, an if (cleanup) await cleanup(); } }); - -test("home quota stays out of the host footer row at wide and narrow widths", async () => { - await build(); - const { default: plugin } = await import(new URL("../dist/tui.js", import.meta.url).href); - let homeRender: HomeSlotRender | undefined; - const contextFixture = { - client: { - rpc: () => ({ - status: async () => status("home", 90), - events: { on: () => () => undefined }, - }), - }, - ui: { - slot(claim: { after?: "prompt.footer"; render: HomeSlotRender }) { - if (claim.after === "prompt.footer") homeRender = claim.render; - return () => undefined; - }, - router: { current: () => ({ type: "home" as const }) }, - }, - theme: { text: { default: "#ffffff", muted: "#888888" } }, - }; - // SAFETY: This focused fixture implements every context member read by slot setup/rendering. - const cleanup = await plugin.setup(Object.assign({} as Plugin.Context, contextFixture)); - if (!homeRender) throw new Error("Plugin did not register the home slot"); - const renderHome = homeRender; - try { - for (const width of [75, 40]) { - const footer = (quota: boolean) => - jsx("box", { - flexDirection: "row", - children: [ - jsx("text", { children: "/project" }), - jsx("box", { flexGrow: 1 }), - jsx("text", { children: "NORMAL" }), - jsx("text", { children: " ctrl+k commands" }), - quota ? renderHome({ mode: "normal", showDetails: false }) : undefined, - ], - }); - const baseline = await testRender(() => footer(false), { width, height: 6 }); - const rendered = await testRender(() => footer(true), { width, height: 6 }); - try { - await baseline.renderOnce(); - await rendered.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); - const baselineRows = baseline.captureCharFrame().split("\n"); - const quotaRows = rendered.captureCharFrame().split("\n"); - expect(quotaRows[0]).toBe(baselineRows[0]); - expect(quotaRows[0]).toContain("/project"); - expect(quotaRows[0]).toContain("NORMAL ctrl+k commands"); - expect(quotaRows[1]?.trim()).toBe(""); - expect(quotaRows[2]?.trim()).toBe("ChatGPT quota (home)"); - expect(quotaRows[3]?.trim()).toBe("5h: 90% · due"); - expect(quotaRows[2]?.indexOf("ChatGPT quota (home)")).toBe( - Math.ceil((width - "ChatGPT quota (home)".length) / 2), - ); - } finally { - baseline.renderer.destroy(); - rendered.renderer.destroy(); - } - } - } finally { - if (cleanup) await cleanup(); - } -});