Compare commits
4
Commits
v3.2.0
...
d7f67f8d4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f67f8d4f | ||
|
|
59da616bc5 | ||
|
|
6f86d1af28 | ||
|
|
deaa4bfd07 |
@@ -1,3 +1,7 @@
|
|||||||
# CI-safe tests and scripts
|
# CI-safe tests and scripts
|
||||||
|
|
||||||
Do not assume local directories, files, tools, or generated artifacts exist in a clean CI environment. In particular, `/tmp/opencode` is an agent-environment convenience, not a CI guarantee. Tests should create temporary directories with `node:os` `tmpdir()` and `mkdtemp(join(tmpdir(), prefix))`, explicitly create required parent directories and fixtures, and clean up the files they create.
|
Do not assume local directories, files, tools, or generated artifacts exist in a clean CI environment. In particular, `/tmp/opencode` is an agent-environment convenience, not a CI guarantee. Tests should create temporary directories with `node:os` `tmpdir()` and `mkdtemp(join(tmpdir(), prefix))`, explicitly create required parent directories and fixtures, and clean up the files they create.
|
||||||
|
|
||||||
|
# README
|
||||||
|
|
||||||
|
Keep README.md to the absolutely required sections (installation, configuration, usage) with at most one short sentence of prose per section, plus code blocks.
|
||||||
|
|||||||
@@ -1,80 +1,44 @@
|
|||||||
# stanp-opencode
|
# stanp-opencode
|
||||||
|
|
||||||
## Release installation
|
## Installation
|
||||||
|
|
||||||
Requires OpenCode 2.0.22, Bun 1.4.2+, Bash, curl, jq, and `sha256sum` on Linux/WSL.
|
Requires OpenCode 2.0.22, Bun 1.4.2+, Bash, curl, jq and `sha256sum` on Linux/WSL.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash -s -- --plugins agents,multi-work,quota,optchat
|
curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash -s -- --plugins agents,multi-work,quota,optchat
|
||||||
```
|
```
|
||||||
|
|
||||||
Choose any plugin subset; Multi-work requires Agents first.
|
## Configuration
|
||||||
|
|
||||||
Quota shows ChatGPT limits for the connected OpenAI account and, when Claude Code is logged in, Claude subscription limits read from `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (read-only; Claude Code refreshes the token).
|
Fill in the empty values the installer writes to `~/.config/opencode/opencode.json`; empty plugins stay inactive.
|
||||||
|
|
||||||
Optchat implements [VictorTaelin's optchat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449). It is off by default; run `/optchat on`, `/optchat off`, or `/optchat status` in a root session. In an enabled session, each turn sees a constant-size view of the whole chat, made of one-line summaries, plus the new message. The model can open any line with the `zoom` tool and read message times with `date`. Optchat does not apply to subagent sessions. Memory is stored in `~/.local/share/opencode/optchat/<sessionID>/`. Compactions run in a child session named "optchat compactions". The plugin accepts two options: `compactionModel` (default `opencode/claude-haiku-5-5#xhigh`) and `dataDir`.
|
|
||||||
|
|
||||||
## Local installation
|
|
||||||
|
|
||||||
From a local checkout, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bash /path/to/stanp-opencode/install-local.sh --plugins agents,multi-work,quota
|
|
||||||
opencode service restart
|
|
||||||
# Connect OpenAI and select coordinate in OpenCode.
|
|
||||||
```
|
|
||||||
|
|
||||||
## GPT configuration
|
|
||||||
|
|
||||||
Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates.
|
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
// Required at the root and in worktrees.
|
|
||||||
"experimental": { "subagent_depth": 2 },
|
"experimental": { "subagent_depth": 2 },
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"package": "./plugins/stanp-opencode/agents",
|
"package": "./plugins/stanp-opencode/agents",
|
||||||
"options": {
|
"options": {
|
||||||
"mode": "gpt",
|
|
||||||
"provider": "openai",
|
|
||||||
// Priority may cost more; set false to opt out.
|
|
||||||
"lunaPriority": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ "package": "./plugins/stanp-opencode/multi-work" },
|
|
||||||
{ "package": "./plugins/stanp-opencode/quota" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom model configuration
|
|
||||||
|
|
||||||
Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates. The `deep-lo` and `deep-hi` assignments are optional as a pair; if either is omitted, both deep workers are disabled and orchestration uses `good-lo`.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
// Required at the root and in worktrees.
|
|
||||||
"experimental": { "subagent_depth": 2 },
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"package": "./plugins/stanp-opencode/agents",
|
|
||||||
"options": {
|
|
||||||
"mode": "custom",
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"fast-lo": { "model": "openai/gpt-6-luna", "effort": "low" },
|
"fast-lo": "openai/gpt-6-luna#low",
|
||||||
"fast-hi": { "model": "openai/gpt-6-luna", "effort": "high" },
|
"fast-hi": "openai/gpt-6-luna#high",
|
||||||
"good-lo": { "model": "openai/gpt-6.1-sol", "effort": "low" },
|
"good-lo": "openai/gpt-6.1-sol#low",
|
||||||
"good-hi": { "model": "openai/gpt-6.1-sol", "effort": "high" },
|
"good-hi": "openai/gpt-6.1-sol#high",
|
||||||
"deep-lo": { "model": "openai/gpt-6-astra", "effort": "low" },
|
"deep-lo": "openai/gpt-6-astra#low",
|
||||||
"deep-hi": { "model": "openai/gpt-6-astra", "effort": "high" },
|
"deep-hi": "openai/gpt-6-astra#high",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ "package": "./plugins/stanp-opencode/multi-work" },
|
{ "package": "./plugins/stanp-opencode/multi-work" },
|
||||||
{ "package": "./plugins/stanp-opencode/quota" },
|
{ "package": "./plugins/stanp-opencode/quota" },
|
||||||
|
{
|
||||||
|
"package": "./plugins/stanp-opencode/optchat",
|
||||||
|
"options": { "compactionModel": "opencode/claude-haiku-5-5#xhigh", "dataDir": "" },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run `/optchat on|off|status` in a root session to toggle optchat.
|
||||||
|
|||||||
+39
-3
@@ -9,10 +9,40 @@ const entrySchema = z
|
|||||||
.object({ package: z.string(), options: z.record(z.string(), z.json()).optional() })
|
.object({ package: z.string(), options: z.record(z.string(), z.json()).optional() })
|
||||||
.catchall(z.json());
|
.catchall(z.json());
|
||||||
type Entry = z.infer<typeof entrySchema>;
|
type Entry = z.infer<typeof entrySchema>;
|
||||||
|
type Options = NonNullable<Entry["options"]>;
|
||||||
|
|
||||||
|
// Every option the plugins read, empty so users can fill them in; plugins treat "" as unset.
|
||||||
|
function skeleton(name: (typeof names)[number]): Options {
|
||||||
|
if (name === "agents")
|
||||||
|
return {
|
||||||
|
agents: Object.fromEntries(
|
||||||
|
["fast-lo", "fast-hi", "good-lo", "good-hi", "deep-lo", "deep-hi"].map((worker) => [
|
||||||
|
worker,
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (name === "optchat") return { compactionModel: "", dataDir: "" };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const nested = z.record(z.string(), z.json());
|
||||||
|
/** Adds missing keys from `defaults` without overwriting existing values. */
|
||||||
|
function fill(target: Options, defaults: Options) {
|
||||||
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
|
const existing = nested.safeParse(target[key]);
|
||||||
|
const missing = nested.safeParse(value);
|
||||||
|
if (target[key] === undefined) target[key] = structuredClone(value);
|
||||||
|
else if (existing.success && missing.success) {
|
||||||
|
fill(existing.data, missing.data);
|
||||||
|
target[key] = existing.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const pluginSchema = z.union([z.string(), entrySchema]);
|
const pluginSchema = z.union([z.string(), entrySchema]);
|
||||||
const registrationSchema = z
|
const registrationSchema = z
|
||||||
.union([z.string().transform((packageName) => ({ package: packageName })), entrySchema])
|
.union([z.string().transform((packageName) => ({ package: packageName })), entrySchema])
|
||||||
.pipe(entrySchema);
|
.pipe(entrySchema);
|
||||||
|
const experimentalSchema = z.object({ subagent_depth: z.number().optional() }).catchall(z.json());
|
||||||
const configSchema = z
|
const configSchema = z
|
||||||
.object({ plugins: z.array(pluginSchema).optional(), $schema: z.string().optional() })
|
.object({ plugins: z.array(pluginSchema).optional(), $schema: z.string().optional() })
|
||||||
.catchall(z.json());
|
.catchall(z.json());
|
||||||
@@ -105,10 +135,9 @@ export async function install(source: string, config: string, selection?: string
|
|||||||
(name === "agents" && monolith(entry.package)),
|
(name === "agents" && monolith(entry.package)),
|
||||||
);
|
);
|
||||||
const options: NonNullable<Entry["options"]> = {};
|
const options: NonNullable<Entry["options"]> = {};
|
||||||
if (name === "agents" && !prior?.options)
|
|
||||||
Object.assign(options, { mode: "gpt", provider: "openai", lunaPriority: true });
|
|
||||||
Object.assign(options, prior?.options);
|
Object.assign(options, prior?.options);
|
||||||
delete options.playwright;
|
delete options.playwright;
|
||||||
|
fill(options, skeleton(name));
|
||||||
const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` });
|
const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` });
|
||||||
delete entry.options;
|
delete entry.options;
|
||||||
if (Object.keys(options).length) entry.options = options;
|
if (Object.keys(options).length) entry.options = options;
|
||||||
@@ -117,11 +146,18 @@ export async function install(source: string, config: string, selection?: string
|
|||||||
const first = configured.findIndex((entry) => isOwned(entry.package));
|
const first = configured.findIndex((entry) => isOwned(entry.package));
|
||||||
const next = entries.filter((_entry, index) => !isOwned(configured[index]!.package));
|
const next = entries.filter((_entry, index) => !isOwned(configured[index]!.package));
|
||||||
next.splice(first < 0 ? next.length : first, 0, ...registrations);
|
next.splice(first < 0 ? next.length : first, 0, ...registrations);
|
||||||
const updated = {
|
const updated: z.infer<typeof configSchema> = {
|
||||||
...current,
|
...current,
|
||||||
$schema: current.$schema ?? "https://opencode.ai/config.json",
|
$schema: current.$schema ?? "https://opencode.ai/config.json",
|
||||||
plugins: next,
|
plugins: next,
|
||||||
};
|
};
|
||||||
|
// Coordinate delegates to workers from Multi-work children, so routing needs depth >= 2.
|
||||||
|
const experimental = experimentalSchema.safeParse(current.experimental ?? {});
|
||||||
|
if (selected.includes("agents") && experimental.success)
|
||||||
|
updated.experimental = {
|
||||||
|
...experimental.data,
|
||||||
|
subagent_depth: Math.max(experimental.data.subagent_depth ?? 0, 2),
|
||||||
|
};
|
||||||
const parent = join(config, "plugins");
|
const parent = join(config, "plugins");
|
||||||
await mkdir(parent, { recursive: true });
|
await mkdir(parent, { recursive: true });
|
||||||
const staged = join(parent, `.stanp-opencode.new.${process.pid}`);
|
const staged = join(parent, `.stanp-opencode.new.${process.pid}`);
|
||||||
|
|||||||
+37
-53
@@ -1,7 +1,8 @@
|
|||||||
import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
|
import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
|
||||||
import type { ModelInfo } from "@opencode/client";
|
import type { ModelInfo } from "@opencode/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { gptAgents, settings, workerNames } from "./config.js";
|
import { settings, workerNames } from "./config.js";
|
||||||
|
import { model } from "./model.js";
|
||||||
import { discover, modelKey } from "./provider.js";
|
import { discover, modelKey } from "./provider.js";
|
||||||
import { descriptions, routingGuide } from "./routing.js";
|
import { descriptions, routingGuide } from "./routing.js";
|
||||||
import { Skill } from "@opencode/schema/skill";
|
import { Skill } from "@opencode/schema/skill";
|
||||||
@@ -37,12 +38,16 @@ const delegation = z.object({ agent: z.string() });
|
|||||||
export default Plugin.define({
|
export default Plugin.define({
|
||||||
id: "stanp-opencode.agents",
|
id: "stanp-opencode.agents",
|
||||||
async setup(context) {
|
async setup(context) {
|
||||||
const selected = settings.parse({ mode: "gpt", ...context.options });
|
const { agents } = settings.parse(context.options);
|
||||||
const lunaPriority =
|
if (!workerNames.some((name) => agents?.[name])) return;
|
||||||
selected.mode === "gpt" &&
|
const assignments: Partial<Record<(typeof workerNames)[number], ReturnType<typeof model>>> =
|
||||||
selected.provider === "openai" &&
|
{};
|
||||||
selected.lunaPriority === true;
|
for (const name of workerNames) {
|
||||||
let models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
|
const reference = agents?.[name];
|
||||||
|
if (reference) assignments[name] = model(reference, `agents ${name}`);
|
||||||
|
else if (!name.startsWith("deep-")) throw new Error(`agents ${name} is required`);
|
||||||
|
}
|
||||||
|
let models = new Map<string, ModelInfo>();
|
||||||
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
||||||
Agent.ID.make("build"),
|
Agent.ID.make("build"),
|
||||||
).permissions;
|
).permissions;
|
||||||
@@ -64,7 +69,7 @@ export default Plugin.define({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (event.type === "model.updated") {
|
if (event.type === "model.updated") {
|
||||||
models = await discover(context, lunaPriority);
|
models = await discover(context);
|
||||||
await context.agent.reload();
|
await context.agent.reload();
|
||||||
} else if (event.type === "agent.updated") {
|
} else if (event.type === "agent.updated") {
|
||||||
// The configured build transform may run after ours. Read the final
|
// The configured build transform may run after ours. Read the final
|
||||||
@@ -84,25 +89,13 @@ export default Plugin.define({
|
|||||||
if (!controller.signal.aborted)
|
if (!controller.signal.aborted)
|
||||||
console.error("stanp-opencode model update failed", error);
|
console.error("stanp-opencode model update failed", error);
|
||||||
});
|
});
|
||||||
models = await discover(context, lunaPriority);
|
models = await discover(context);
|
||||||
const { data: initialAgents } = await context.agent.list();
|
const { data: initialAgents } = await context.agent.list();
|
||||||
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||||
permissionKey = JSON.stringify(inherited);
|
permissionKey = JSON.stringify(inherited);
|
||||||
const registration = await context.rpc.register(AgentsRpc, {
|
const registration = await context.rpc.register(AgentsRpc, {
|
||||||
permissions: async () => inherited.map((rule) => ({ ...rule })),
|
permissions: async () => inherited.map((rule) => ({ ...rule })),
|
||||||
});
|
});
|
||||||
const assignments =
|
|
||||||
selected.mode === "gpt"
|
|
||||||
? Object.fromEntries(
|
|
||||||
workerNames.map((name) => [
|
|
||||||
name,
|
|
||||||
{
|
|
||||||
...gptAgents[name],
|
|
||||||
provider: selected.provider,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
: selected.agents;
|
|
||||||
let workers: (typeof workerNames)[number][] = [];
|
let workers: (typeof workerNames)[number][] = [];
|
||||||
const active = new Set<string>();
|
const active = new Set<string>();
|
||||||
|
|
||||||
@@ -136,19 +129,8 @@ export default Plugin.define({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.agent.transform((editor) => {
|
await context.agent.transform((editor) => {
|
||||||
const coordinates = (assignment: { model: string }) => {
|
const availableFor = (assignment: { providerID: string; id: string }) =>
|
||||||
if (selected.mode === "gpt")
|
models.get(modelKey(assignment.providerID, assignment.id));
|
||||||
return { provider: selected.provider, model: assignment.model };
|
|
||||||
const slash = assignment.model.indexOf("/");
|
|
||||||
return {
|
|
||||||
provider: assignment.model.slice(0, slash),
|
|
||||||
model: assignment.model.slice(slash + 1),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const availableFor = (assignment: { model: string }) => {
|
|
||||||
const { provider, model } = coordinates(assignment);
|
|
||||||
return models.get(modelKey(provider, model));
|
|
||||||
};
|
|
||||||
const deepLo = assignments["deep-lo"];
|
const deepLo = assignments["deep-lo"];
|
||||||
const deep =
|
const deep =
|
||||||
deepLo !== undefined &&
|
deepLo !== undefined &&
|
||||||
@@ -163,30 +145,31 @@ export default Plugin.define({
|
|||||||
: [];
|
: [];
|
||||||
active.clear();
|
active.clear();
|
||||||
for (const name of workers) active.add(name);
|
for (const name of workers) active.add(name);
|
||||||
if (workers.length === 0) return;
|
if (workers.length === 0 || coordinate === undefined) return;
|
||||||
for (const name of workers) {
|
for (const name of workers) {
|
||||||
const assignment = assignments[name]!;
|
const assignment = assignments[name]!;
|
||||||
const available = availableFor(assignment)!;
|
const available = availableFor(assignment)!;
|
||||||
editor.update(name, (agent) => {
|
editor.update(name, (agent) => {
|
||||||
const hasVariant = available.model.variants.some(
|
const hasVariant =
|
||||||
({ id }) => id === assignment.effort,
|
assignment.variant !== undefined &&
|
||||||
);
|
available.variants.some(({ id }) => id === assignment.variant);
|
||||||
agent.name = Agent.Name.make(name);
|
agent.name = Agent.Name.make(name);
|
||||||
agent.mode = "subagent";
|
agent.mode = "subagent";
|
||||||
agent.hidden = false;
|
agent.hidden = false;
|
||||||
agent.description = descriptions[name];
|
agent.description = descriptions[name];
|
||||||
agent.model = {
|
agent.model = {
|
||||||
providerID: Provider.ID.make(available.model.providerID),
|
providerID: Provider.ID.make(available.providerID),
|
||||||
id: Model.ID.make(available.model.id),
|
id: Model.ID.make(available.id),
|
||||||
};
|
};
|
||||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.effort);
|
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.variant!);
|
||||||
agent.request = {
|
agent.request = {
|
||||||
settings: hasVariant ? {} : { reasoningEffort: assignment.effort },
|
settings:
|
||||||
|
hasVariant || assignment.variant === undefined
|
||||||
|
? {}
|
||||||
|
: { reasoningEffort: assignment.variant },
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
};
|
};
|
||||||
if (lunaPriority && available.serviceTier)
|
|
||||||
agent.request.settings.serviceTier = available.serviceTier;
|
|
||||||
agent.permissions = [
|
agent.permissions = [
|
||||||
...structuredClone(inherited),
|
...structuredClone(inherited),
|
||||||
{ action: "subagent", resource: "*", effect: "deny" },
|
{ action: "subagent", resource: "*", effect: "deny" },
|
||||||
@@ -195,26 +178,27 @@ export default Plugin.define({
|
|||||||
}
|
}
|
||||||
editor.update("coordinate", (agent) => {
|
editor.update("coordinate", (agent) => {
|
||||||
const available = availableFor(coordinate)!;
|
const available = availableFor(coordinate)!;
|
||||||
const hasVariant = available.model.variants.some(
|
const hasVariant =
|
||||||
({ id }) => id === coordinate.effort,
|
coordinate.variant !== undefined &&
|
||||||
);
|
available.variants.some(({ id }) => id === coordinate.variant);
|
||||||
agent.name = Agent.Name.make("coordinate");
|
agent.name = Agent.Name.make("coordinate");
|
||||||
agent.mode = "primary";
|
agent.mode = "primary";
|
||||||
agent.hidden = false;
|
agent.hidden = false;
|
||||||
agent.description =
|
agent.description =
|
||||||
"Read, plan, and coordinate specialist workers; never implement directly.";
|
"Read, plan, and coordinate specialist workers; never implement directly.";
|
||||||
agent.model = {
|
agent.model = {
|
||||||
providerID: Provider.ID.make(available.model.providerID),
|
providerID: Provider.ID.make(available.providerID),
|
||||||
id: Model.ID.make(available.model.id),
|
id: Model.ID.make(available.id),
|
||||||
};
|
};
|
||||||
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort);
|
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.variant!);
|
||||||
agent.request = {
|
agent.request = {
|
||||||
settings: hasVariant ? {} : { reasoningEffort: coordinate.effort },
|
settings:
|
||||||
|
hasVariant || coordinate.variant === undefined
|
||||||
|
? {}
|
||||||
|
: { reasoningEffort: coordinate.variant },
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
};
|
};
|
||||||
if (lunaPriority && available.serviceTier)
|
|
||||||
agent.request.settings.serviceTier = available.serviceTier;
|
|
||||||
agent.system = routingGuide(deep);
|
agent.system = routingGuide(deep);
|
||||||
agent.permissions = [
|
agent.permissions = [
|
||||||
{ action: "*", resource: "*", effect: "deny" },
|
{ action: "*", resource: "*", effect: "deny" },
|
||||||
|
|||||||
+6
-57
@@ -9,61 +9,10 @@ export const workerNames = [
|
|||||||
"deep-hi",
|
"deep-hi",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const gptAssignment = z.strictObject({
|
// Each assignment is provider/model[#variant]; an empty string means unset. Without any
|
||||||
model: z.string().trim().min(1),
|
// assignment the plugin registers nothing.
|
||||||
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]),
|
export const settings = z.object({
|
||||||
|
agents: z
|
||||||
|
.strictObject(Object.fromEntries(workerNames.map((name) => [name, z.string().optional()])))
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const customAssignment = gptAssignment.extend({
|
|
||||||
model: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.refine((model) => {
|
|
||||||
const slash = model.indexOf("/");
|
|
||||||
return slash > 0 && slash < model.length - 1;
|
|
||||||
}, "model must use the provider/model convention"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const settings = z.discriminatedUnion("mode", [
|
|
||||||
z
|
|
||||||
.strictObject({
|
|
||||||
mode: z.literal("gpt"),
|
|
||||||
provider: z.string().trim().min(1).default("openai"),
|
|
||||||
lunaPriority: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.transform((value, context) => {
|
|
||||||
if (value.provider !== "openai" && value.lunaPriority !== undefined) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["lunaPriority"],
|
|
||||||
message: "lunaPriority is only supported by the openai provider",
|
|
||||||
});
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
return value.provider === "openai"
|
|
||||||
? { ...value, lunaPriority: value.lunaPriority ?? true }
|
|
||||||
: value;
|
|
||||||
}),
|
|
||||||
z.strictObject({
|
|
||||||
mode: z.literal("custom"),
|
|
||||||
agents: z.strictObject({
|
|
||||||
"fast-lo": customAssignment,
|
|
||||||
"fast-hi": customAssignment,
|
|
||||||
"good-lo": customAssignment,
|
|
||||||
"good-hi": customAssignment,
|
|
||||||
"deep-lo": customAssignment.optional(),
|
|
||||||
"deep-hi": customAssignment.optional(),
|
|
||||||
// Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo.
|
|
||||||
coordinate: customAssignment.optional(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const gptAgents = {
|
|
||||||
"fast-lo": { model: "gpt-6-luna", effort: "low" },
|
|
||||||
"fast-hi": { model: "gpt-6-luna", effort: "high" },
|
|
||||||
"good-lo": { model: "gpt-6.1-sol", effort: "low" },
|
|
||||||
"good-hi": { model: "gpt-6.1-sol", effort: "high" },
|
|
||||||
"deep-lo": { model: "gpt-6-astra", effort: "low" },
|
|
||||||
"deep-hi": { model: "gpt-6-astra", effort: "high" },
|
|
||||||
} satisfies Record<(typeof workerNames)[number], z.infer<typeof gptAssignment>>;
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Parses `provider/model[#variant]`; `name` prefixes the error for the offending option. */
|
||||||
|
export function model(reference: string, name: string) {
|
||||||
|
const [path = "", variant] = reference.split("#");
|
||||||
|
const slash = path.indexOf("/");
|
||||||
|
if (slash <= 0) throw new Error(`${name} must be provider/model[#variant]: ${reference}`);
|
||||||
|
const providerID = path.slice(0, slash);
|
||||||
|
const id = path.slice(slash + 1);
|
||||||
|
return variant ? { providerID, id, variant } : { providerID, id };
|
||||||
|
}
|
||||||
+10
-14
@@ -34,13 +34,18 @@ import {
|
|||||||
type Line,
|
type Line,
|
||||||
} from "./optchat-core.js";
|
} from "./optchat-core.js";
|
||||||
import { Memory } from "./optchat-memory.js";
|
import { Memory } from "./optchat-memory.js";
|
||||||
|
import { model } from "./model.js";
|
||||||
|
|
||||||
const settings = z.object({
|
const settings = z.object({
|
||||||
/** provider/model#variant used for compactions. */
|
/** provider/model[#variant] used for compactions; without it optchat does not apply. */
|
||||||
compactionModel: z.string().default("opencode/claude-haiku-5-5#xhigh"),
|
compactionModel: z.string().optional(),
|
||||||
|
/** Empty means the default directory. */
|
||||||
dataDir: z
|
dataDir: z
|
||||||
.string()
|
.string()
|
||||||
.default(
|
.optional()
|
||||||
|
.transform(
|
||||||
|
(value) =>
|
||||||
|
value ||
|
||||||
join(
|
join(
|
||||||
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -199,21 +204,12 @@ function turnStart(history: readonly Entry[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function model(reference: string) {
|
|
||||||
const [path = "", variant] = reference.split("#");
|
|
||||||
const slash = path.indexOf("/");
|
|
||||||
if (slash <= 0)
|
|
||||||
throw new Error(`optchat compactionModel must be provider/model[#variant]: ${reference}`);
|
|
||||||
const providerID = path.slice(0, slash);
|
|
||||||
const id = path.slice(slash + 1);
|
|
||||||
return variant ? { providerID, id, variant } : { providerID, id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Plugin.define({
|
export default Plugin.define({
|
||||||
id: "stanp-opencode.optchat",
|
id: "stanp-opencode.optchat",
|
||||||
setup: async (context) => {
|
setup: async (context) => {
|
||||||
const options = settings.parse(context.options);
|
const options = settings.parse(context.options);
|
||||||
const compactionModel = model(options.compactionModel);
|
if (!options.compactionModel) return;
|
||||||
|
const compactionModel = model(options.compactionModel, "optchat compactionModel");
|
||||||
const chats = new Map<string, Promise<Chat>>();
|
const chats = new Map<string, Promise<Chat>>();
|
||||||
const enabled = new Map<string, boolean>();
|
const enabled = new Map<string, boolean>();
|
||||||
const workers = new Map<string, string>();
|
const workers = new Map<string, string>();
|
||||||
|
|||||||
+3
-26
@@ -1,39 +1,16 @@
|
|||||||
import type { Plugin } from "@opencode/plugin";
|
import type { Plugin } from "@opencode/plugin";
|
||||||
import type { ModelInfo } from "@opencode/client";
|
import type { ModelInfo } from "@opencode/client";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
function priority(model: ModelInfo) {
|
|
||||||
if (model.modelID !== "gpt-6-luna") return undefined;
|
|
||||||
for (const settings of [model.settings, ...model.variants.map((variant) => variant.settings)]) {
|
|
||||||
if (settings?.serviceTier === "priority" || settings?.serviceTier === "fast")
|
|
||||||
return settings.serviceTier;
|
|
||||||
}
|
|
||||||
const baseURL = model.settings?.baseURL;
|
|
||||||
if (model.package !== "@opencode/ai/providers/openai" && model.package !== "@ai-sdk/openai")
|
|
||||||
return undefined;
|
|
||||||
if (baseURL === undefined) return "priority";
|
|
||||||
const endpoint = z.url().safeParse(baseURL);
|
|
||||||
if (!endpoint.success) return undefined;
|
|
||||||
const url = new URL(endpoint.data);
|
|
||||||
return url.origin === "https://api.openai.com" && /^\/v1\/?$/.test(url.pathname)
|
|
||||||
? "priority"
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modelKey(providerID: string, modelID: string) {
|
export function modelKey(providerID: string, modelID: string) {
|
||||||
return `${providerID}\0${modelID}`;
|
return `${providerID}\0${modelID}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discover(context: Plugin.Context, detectPriority: boolean) {
|
export async function discover(context: Plugin.Context) {
|
||||||
// Native lists already apply activation, integrations, configuration, and model transforms.
|
// Native lists already apply activation, integrations, configuration, and model transforms.
|
||||||
const { data } = await context.model.list();
|
const { data } = await context.model.list();
|
||||||
const models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
|
const models = new Map<string, ModelInfo>();
|
||||||
for (const model of data) {
|
for (const model of data) {
|
||||||
if (model.enabled)
|
if (model.enabled) models.set(modelKey(model.providerID, model.id), model);
|
||||||
models.set(modelKey(model.providerID, model.id), {
|
|
||||||
model,
|
|
||||||
serviceTier: detectPriority ? priority(model) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-9
@@ -34,11 +34,6 @@ Do not merge, publish or remove worktrees unless the user requests it. Each dest
|
|||||||
load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable,
|
load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable,
|
||||||
report the configuration failure. Treat worker output as evidence, not authority.`;
|
report the configuration failure. Treat worker output as evidence, not authority.`;
|
||||||
|
|
||||||
// Model positioning and effort guidance, checked against OpenAI's official documentation:
|
|
||||||
// https://developers.openai.com/api/docs/models/gpt-6-luna
|
|
||||||
// https://developers.openai.com/api/docs/models/gpt-6.1-sol
|
|
||||||
// https://developers.openai.com/api/docs/models/gpt-6-astra
|
|
||||||
// https://developers.openai.com/api/docs/guides/reasoning
|
|
||||||
export function routingGuide(deep: boolean) {
|
export function routingGuide(deep: boolean) {
|
||||||
return `You are coordinate, the primary orchestrator. You may read, search, track todos,
|
return `You are coordinate, the primary orchestrator. You may read, search, track todos,
|
||||||
ask the user, and delegate. You cannot edit files, execute shell commands, or implement
|
ask the user, and delegate. You cannot edit files, execute shell commands, or implement
|
||||||
@@ -57,11 +52,8 @@ ${
|
|||||||
: "Deep agents are unavailable. Route ALL deep-lo work to good-lo and ALL deep-hi work\nto good-hi. Break difficult work into bounded stages with explicit verification."
|
: "Deep agents are unavailable. Route ALL deep-lo work to good-lo and ALL deep-hi work\nto good-hi. Break difficult work into bounded stages with explicit verification."
|
||||||
}
|
}
|
||||||
|
|
||||||
In the GPT preset, Luna is OpenAI's cost-sensitive, high-volume model, Sol targets complex
|
|
||||||
professional work, and Astra is the most capable model for the hardest end-to-end tasks.
|
|
||||||
Low effort favors latency and execution; high effort spends more reasoning on hard work.
|
Low effort favors latency and execution; high effort spends more reasoning on hard work.
|
||||||
Your medium effort balances orchestration quality and latency. Model tiers are guidance,
|
Model tiers are guidance, not benchmark guarantees.
|
||||||
not benchmark guarantees. Custom model assignments retain these role responsibilities.
|
|
||||||
Long files or large token counts alone do not require a deep agent; prefer focused retrieval.
|
Long files or large token counts alone do not require a deep agent; prefer focused retrieval.
|
||||||
|
|
||||||
Delegate self-contained assignments: goal, relevant context/files, constraints, ownership,
|
Delegate self-contained assignments: goal, relevant context/files, constraints, ownership,
|
||||||
|
|||||||
+5
-124
@@ -120,45 +120,8 @@ test("reads credentials file and sends OAuth usage request", async () => {
|
|||||||
await tracker.dispose();
|
await tracker.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing credentials file yields no status", async () => {
|
test("failed fetches retry with re-read credentials and give up after five attempts", 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));
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
||||||
const tokens: Array<string | null> = [];
|
const tokens: Array<string | null> = [];
|
||||||
let changed = 0;
|
let changed = 0;
|
||||||
@@ -184,63 +147,8 @@ test("failed fetch retries with re-read credentials", async () => {
|
|||||||
expect(tracker.status()?.plan).toBe("max");
|
expect(tracker.status()?.plan).toBe("max");
|
||||||
expect(changed).toBe(1);
|
expect(changed).toBe(1);
|
||||||
await tracker.dispose();
|
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));
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const tracker = new ClaudeQuotaTracker(
|
const tracker = new ClaudeQuotaTracker(
|
||||||
@@ -259,32 +167,5 @@ test("gives up after five failed attempts", async () => {
|
|||||||
await tracker.refresh();
|
await tracker.refresh();
|
||||||
expect(calls).toBe(10);
|
expect(calls).toBe(10);
|
||||||
await tracker.dispose();
|
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,527 +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<string, NonNullable<ReturnType<AgentEditor["get"]>>>;
|
|
||||||
admit: () => Effect.Effect<void[]>;
|
|
||||||
}
|
|
||||||
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<string, Session.Info>();
|
|
||||||
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<string, LocationInstance>(),
|
|
||||||
admissions,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string) {
|
|
||||||
const location = { directory };
|
|
||||||
const agents = new Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>();
|
|
||||||
const transforms: ((editor: AgentEditor) => void)[] = [];
|
|
||||||
const prompts: ((event: SessionHooks["prompt"]) => Effect.Effect<void>)[] = [];
|
|
||||||
const tools = new Map<string, Tool.Info>();
|
|
||||||
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
|
||||||
let lateBuild: Agent.Info["permissions"] = [];
|
|
||||||
let lateMulti: Agent.Info["permissions"] = [];
|
|
||||||
let reloads = 0;
|
|
||||||
const updates = Effect.runSync(
|
|
||||||
PubSub.unbounded<Schema.Schema.Type<typeof Agent.Event.Updated>>(),
|
|
||||||
);
|
|
||||||
const registration = Effect.succeed({ dispose: Effect.void });
|
|
||||||
let permissions: (() => Effect.Effect<Agent.Info["permissions"]>) | 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: { provider: "fixture" },
|
|
||||||
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<string, NonNullable<ReturnType<SkillEditor["get"]>>>();
|
|
||||||
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<Agent.Info["permissions"]> },
|
|
||||||
) =>
|
|
||||||
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<void>,
|
|
||||||
) =>
|
|
||||||
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<Context, "options">,
|
|
||||||
) => ReturnType<typeof plugin.effect>;
|
|
||||||
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);
|
|
||||||
@@ -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 () => {
|
test("closed, linked and ambiguous workspaces fail root gating", async () => {
|
||||||
for (const mode of ["close", "link", "duplicate"] as const) {
|
for (const mode of ["close", "link", "duplicate"] as const) {
|
||||||
const f = await fixture();
|
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();
|
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");
|
|
||||||
});
|
|
||||||
|
|||||||
+78
-175
@@ -1,15 +1,5 @@
|
|||||||
import { afterEach, expect, test } from "bun:test";
|
import { afterEach, expect, test } from "bun:test";
|
||||||
import {
|
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
chmod,
|
|
||||||
mkdir,
|
|
||||||
mkdtemp,
|
|
||||||
readFile,
|
|
||||||
readdir,
|
|
||||||
rm,
|
|
||||||
stat,
|
|
||||||
symlink,
|
|
||||||
writeFile,
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
@@ -24,12 +14,26 @@ const unrelated = {
|
|||||||
permissions: { shell: "ask" },
|
permissions: { shell: "ask" },
|
||||||
mcp: {
|
mcp: {
|
||||||
servers: {
|
servers: {
|
||||||
codegraph: { type: "local", command: ["user-codegraph"], codemode: true },
|
codegraph: {
|
||||||
|
type: "local",
|
||||||
|
command: ["user-codegraph"],
|
||||||
|
codemode: true,
|
||||||
|
},
|
||||||
browser: { type: "local", command: ["user-browser"] },
|
browser: { type: "local", command: ["user-browser"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const names = ["agents", "multi-work", "quota", "optchat"];
|
const names = ["agents", "multi-work", "quota", "optchat"];
|
||||||
|
const empty = {
|
||||||
|
agents: {
|
||||||
|
"fast-lo": "",
|
||||||
|
"fast-hi": "",
|
||||||
|
"good-lo": "",
|
||||||
|
"good-hi": "",
|
||||||
|
"deep-lo": "",
|
||||||
|
"deep-hi": "",
|
||||||
|
},
|
||||||
|
};
|
||||||
const helper = new URL("../scripts/install.ts", import.meta.url);
|
const helper = new URL("../scripts/install.ts", import.meta.url);
|
||||||
async function run(
|
async function run(
|
||||||
command: string[],
|
command: string[],
|
||||||
@@ -73,13 +77,20 @@ async function fixture() {
|
|||||||
join(source, "dist", "tui.js"),
|
join(source, "dist", "tui.js"),
|
||||||
'export default { id: "stanp-opencode.quota-tui", setup() {} };',
|
'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);
|
expect(built.success).toBe(true);
|
||||||
await Bun.write(join(source, "dist", "install.js"), built.outputs[0]!);
|
await Bun.write(join(source, "dist", "install.js"), built.outputs[0]!);
|
||||||
await writeFile(join(source, "package.json"), '{"type":"module"}');
|
await writeFile(join(source, "package.json"), '{"type":"module"}');
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(config, "opencode.json"),
|
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");
|
await writeFile(join(config, "opencode.jsonc"), "// untouched user config\n{}\n");
|
||||||
return { directory, source, config };
|
return { directory, source, config };
|
||||||
@@ -88,20 +99,13 @@ async function contents(config: string) {
|
|||||||
return JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
return JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const selection of [
|
for (const selection of ["quota", "agents,multi-work,quota,optchat"]) {
|
||||||
"agents",
|
|
||||||
"quota",
|
|
||||||
"agents,quota",
|
|
||||||
"agents,multi-work",
|
|
||||||
"agents,multi-work,quota",
|
|
||||||
"optchat",
|
|
||||||
"agents,multi-work,quota,optchat",
|
|
||||||
]) {
|
|
||||||
test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => {
|
test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
await install(f.source, f.config, selection);
|
await install(f.source, f.config, selection);
|
||||||
const result = await contents(f.config);
|
const result = await contents(f.config);
|
||||||
expect(result).toMatchObject(unrelated);
|
expect(result).toMatchObject(unrelated);
|
||||||
|
expect(result.experimental === undefined).toBe(selection === "quota");
|
||||||
expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([
|
expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([
|
||||||
"before",
|
"before",
|
||||||
...selection.split(",").map((name) => `./plugins/stanp-opencode/${name}`),
|
...selection.split(",").map((name) => `./plugins/stanp-opencode/${name}`),
|
||||||
@@ -123,7 +127,9 @@ test("upgrades retain selection and options; explicit selection replaces only ow
|
|||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
await install(f.source, f.config, "agents,quota");
|
await install(f.source, f.config, "agents,quota");
|
||||||
const current = await contents(f.config);
|
const current = await contents(f.config);
|
||||||
current.plugins[1].options = { mode: "gpt", provider: "openai", lunaPriority: false };
|
current.plugins[1].options = {
|
||||||
|
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||||
|
};
|
||||||
current.plugins.push({ package: "after", options: { untouched: 1 } });
|
current.plugins.push({ package: "after", options: { untouched: 1 } });
|
||||||
await writeFile(join(f.config, "opencode.json"), JSON.stringify(current));
|
await writeFile(join(f.config, "opencode.json"), JSON.stringify(current));
|
||||||
await install(f.source, f.config);
|
await install(f.source, f.config);
|
||||||
@@ -136,99 +142,61 @@ 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 () => {
|
test("installer writes empty option skeletons and subagent_depth, fills only missing keys, and is idempotent", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const before = "unrelated-plugin";
|
await install(f.source, f.config, "agents,multi-work,quota,optchat");
|
||||||
const after = { package: "unrelated-object", options: { keep: [1, true, "value"] } };
|
const fresh = await contents(f.config);
|
||||||
|
expect(fresh.plugins.slice(1)).toEqual([
|
||||||
|
{ package: "./plugins/stanp-opencode/agents", options: empty },
|
||||||
|
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||||
|
{ package: "./plugins/stanp-opencode/quota" },
|
||||||
|
{
|
||||||
|
package: "./plugins/stanp-opencode/optchat",
|
||||||
|
options: { compactionModel: "", dataDir: "" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
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);
|
||||||
|
const filled = await contents(f.config);
|
||||||
|
expect(filled.plugins[1].options).toEqual({
|
||||||
|
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||||
|
extra: 1,
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
for (const [depth, expected] of [
|
||||||
|
[1, 2],
|
||||||
|
[3, 3],
|
||||||
|
]) {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(f.config, "opencode.json"),
|
join(f.config, "opencode.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...unrelated,
|
...filled,
|
||||||
plugins: [before, { package: "./plugins/stanp-opencode/quota" }, after],
|
experimental: { subagent_depth: depth, other: true },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await install(f.source, f.config);
|
await install(f.source, f.config);
|
||||||
expect((await contents(f.config)).plugins).toEqual([
|
expect((await contents(f.config)).experimental).toEqual({
|
||||||
before,
|
subagent_depth: expected,
|
||||||
{ package: "./plugins/stanp-opencode/quota" },
|
other: true,
|
||||||
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: { mode: "gpt", provider: "openai", lunaPriority: true },
|
|
||||||
},
|
|
||||||
{ 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: { mode: "gpt", provider: "openai", lunaPriority: true },
|
|
||||||
},
|
|
||||||
{ 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: { mode: "gpt", provider: "openai", lunaPriority: true },
|
|
||||||
},
|
|
||||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
|
||||||
{ package: "./plugins/stanp-opencode/quota" },
|
|
||||||
"after",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("local installer retains executable permissions and supports direct invocation", async () => {
|
test("local installer supports direct invocation", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const path = new URL("../install-local.sh", import.meta.url).pathname;
|
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);
|
const result = await run([path, "--plugins", "multi-work"], f.directory);
|
||||||
expect(result.exitCode).toBe(2);
|
expect(result.exitCode).toBe(2);
|
||||||
expect(result.stderr).toContain("Multi-work requires Agents");
|
expect(result.stderr).toContain("Multi-work requires Agents");
|
||||||
@@ -244,7 +212,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
|||||||
{ package: "before" },
|
{ package: "before" },
|
||||||
{
|
{
|
||||||
package: "./plugins/stanp-opencode",
|
package: "./plugins/stanp-opencode",
|
||||||
options: { mode: "gpt", provider: "openai", lunaPriority: false },
|
options: { agents: { "fast-lo": "openai/gpt-6-luna#low" } },
|
||||||
},
|
},
|
||||||
{ package: "stanp-opencode" },
|
{ package: "stanp-opencode" },
|
||||||
{ package: "after" },
|
{ package: "after" },
|
||||||
@@ -263,7 +231,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
|||||||
.map((name) => `./plugins/stanp-opencode/${name}`),
|
.map((name) => `./plugins/stanp-opencode/${name}`),
|
||||||
"after",
|
"after",
|
||||||
]);
|
]);
|
||||||
expect(result.plugins[1].options.lunaPriority).toBe(false);
|
expect(result.plugins[1].options.agents["fast-lo"]).toBe("openai/gpt-6-luna#low");
|
||||||
expect(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
|
expect(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
|
||||||
expect(result).toMatchObject(unrelated);
|
expect(result).toMatchObject(unrelated);
|
||||||
});
|
});
|
||||||
@@ -284,43 +252,6 @@ test("noninteractive fresh installs require a selection; invalid selections leav
|
|||||||
expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original);
|
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 () => {
|
test("artifact validation failure leaves prior installation and config intact", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
await install(f.source, f.config, "quota");
|
await install(f.source, f.config, "quota");
|
||||||
@@ -342,36 +273,6 @@ test("artifact validation failure leaves prior installation and config intact",
|
|||||||
expect(await readdir(join(f.config, "plugins"))).toEqual(["stanp-opencode"]);
|
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 () => {
|
test("local shell installer builds its checkout and upgrades mixed entries without flags", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const after = { package: "unrelated-object", options: { keep: true } };
|
const after = { package: "unrelated-object", options: { keep: true } };
|
||||||
@@ -417,7 +318,9 @@ for (const validChecksum of [true, false])
|
|||||||
if (validChecksum)
|
if (validChecksum)
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(f.config, "opencode.json"),
|
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");
|
const archiveRoot = join(f.directory, "archive");
|
||||||
await mkdir(archiveRoot);
|
await mkdir(archiveRoot);
|
||||||
|
|||||||
@@ -326,54 +326,6 @@ const request = z.object({
|
|||||||
background: z.boolean().optional(),
|
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 () => {
|
test("coordinate can create and resume a root-local background child without Herdr creation or movement", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
f.parent.permissions = [{ action: "herdr_worktree", resource: "*", effect: "deny" }];
|
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);
|
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 () => {
|
test("root loss, session permission denial and missing destination fail before prompting", async () => {
|
||||||
for (const failure of ["root", "permission", "ask", "destination", "project"] as const) {
|
for (const failure of ["root", "permission", "ask", "destination", "project"] as const) {
|
||||||
const f = fixture();
|
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<void>();
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
+23
-112
@@ -5,17 +5,13 @@ import {
|
|||||||
VIEW_HIGH,
|
VIEW_HIGH,
|
||||||
VIEW_LOW,
|
VIEW_LOW,
|
||||||
before,
|
before,
|
||||||
blocks,
|
|
||||||
bytes,
|
bytes,
|
||||||
clean,
|
clean,
|
||||||
compressTask,
|
compressTask,
|
||||||
contains,
|
|
||||||
grow,
|
grow,
|
||||||
key,
|
key,
|
||||||
merge,
|
merge,
|
||||||
mergeTask,
|
mergeTask,
|
||||||
pieces,
|
|
||||||
render,
|
|
||||||
tooLong,
|
tooLong,
|
||||||
viewBytes,
|
viewBytes,
|
||||||
type Line,
|
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)", () => {
|
test("merges and compaction context never use unbuilt lines", () => {
|
||||||
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", () => {
|
|
||||||
const built = new Set(["1:0"]);
|
const built = new Set(["1:0"]);
|
||||||
const text = (line: Line) => (line[0] === 0 || built.has(key(line)) ? "" : undefined);
|
const text = (line: Line) => (line[0] === 0 || built.has(key(line)) ? "" : undefined);
|
||||||
const view: Line[] = [
|
const view: Line[] = [
|
||||||
@@ -97,9 +77,22 @@ test("merge skips pairs whose parent is unbuilt", () => {
|
|||||||
[0, 2],
|
[0, 2],
|
||||||
[0, 3],
|
[0, 3],
|
||||||
]);
|
]);
|
||||||
|
const context = (line: Line) => (key(line) === "0:5" ? undefined : "t");
|
||||||
|
const zoomed: Line[] = [
|
||||||
|
[2, 0],
|
||||||
|
[0, 4],
|
||||||
|
[0, 5],
|
||||||
|
[0, 6],
|
||||||
|
];
|
||||||
|
expect(before(zoomed, 4, context)).toEqual([[2, 0]]);
|
||||||
|
expect(before(zoomed, 7, context)).toEqual([
|
||||||
|
[2, 0],
|
||||||
|
[0, 4],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("view is a sawtooth: grows past 128 KB, then one batch drops it to 64 KB", () => {
|
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 line = "y".repeat(500);
|
||||||
const text = () => line;
|
const text = () => line;
|
||||||
const view: View = { lines: [], batching: false };
|
const view: View = { lines: [], batching: false };
|
||||||
@@ -128,9 +121,8 @@ test("view is a sawtooth: grows past 128 KB, then one batch drops it to 64 KB",
|
|||||||
next += 2 ** l;
|
next += 2 ** l;
|
||||||
}
|
}
|
||||||
expect(next).toBe(2000);
|
expect(next).toBe(2000);
|
||||||
});
|
}
|
||||||
|
{
|
||||||
test("a batch that cannot reach 64 KB continues at the next message", () => {
|
|
||||||
let parents = false;
|
let parents = false;
|
||||||
const text = (line: Line) => (line[0] === 0 || parents ? "z".repeat(500) : undefined);
|
const text = (line: Line) => (line[0] === 0 || parents ? "z".repeat(500) : undefined);
|
||||||
const view: View = { lines: [], batching: false };
|
const view: View = { lines: [], batching: false };
|
||||||
@@ -144,51 +136,15 @@ test("a batch that cannot reach 64 KB continues at the next message", () => {
|
|||||||
parents = true;
|
parents = true;
|
||||||
// Nothing was built since the last try: the batch waits.
|
// Nothing was built since the last try: the batch waits.
|
||||||
const waiting = grow(view, 260, text, total, false);
|
const waiting = grow(view, 260, text, total, false);
|
||||||
expect(waiting).toEqual({ merged: false, tried: false, total: viewBytes(view.lines, text) });
|
expect(waiting).toEqual({
|
||||||
|
merged: false,
|
||||||
|
tried: false,
|
||||||
|
total: viewBytes(view.lines, text),
|
||||||
|
});
|
||||||
expect(grow(view, 261, text, waiting.total, true).merged).toBe(true);
|
expect(grow(view, 261, text, waiting.total, true).merged).toBe(true);
|
||||||
expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW);
|
expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW);
|
||||||
expect(view.batching).toBe(false);
|
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[] = [
|
|
||||||
[2, 0],
|
|
||||||
[0, 4],
|
|
||||||
[0, 5],
|
|
||||||
[0, 6],
|
|
||||||
];
|
|
||||||
expect(before(view, 4, text)).toEqual([[2, 0]]);
|
|
||||||
expect(before(view, 7, text)).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(["<chat>\n1\n2\n3\n4\n"]);
|
|
||||||
expect(a.tail).toBe("5\n6\n</chat>");
|
|
||||||
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</chat>");
|
|
||||||
expect(blocks([])).toEqual({ whole: [], tail: "<chat>\n</chat>" });
|
|
||||||
});
|
|
||||||
|
|
||||||
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("tasks are verbatim and Too long cuts at 512 bytes", () => {
|
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"]);
|
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 () => {
|
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.
|
// Regression: short leaves build synchronously, so both finishes saw the parent unbuilt.
|
||||||
const tree = new Set<string>();
|
const tree = new Set<string>();
|
||||||
@@ -319,19 +246,3 @@ test("a merge is queued once when both halves finish in the same tick", async ()
|
|||||||
await Bun.sleep(30);
|
await Bun.sleep(30);
|
||||||
expect(starts).toEqual(["0:0", "0:1", "1:0"]);
|
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -55,21 +55,6 @@ test("logs append-only, dedupe by source, split long texts, and reload with tree
|
|||||||
await again.close();
|
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 () => {
|
test("one writer: the lock rejects a live holder and replaces a dead one", async () => {
|
||||||
const directory = await fixture();
|
const directory = await fixture();
|
||||||
const memory = await Memory.open(directory);
|
const memory = await Memory.open(directory);
|
||||||
|
|||||||
+77
-70
@@ -91,7 +91,10 @@ async function host() {
|
|||||||
callback: (editor: {
|
callback: (editor: {
|
||||||
add: (definition: { execute: typeof command }) => void;
|
add: (definition: { execute: typeof command }) => void;
|
||||||
}) => void,
|
}) => void,
|
||||||
) => callback({ add: (definition) => (command = definition.execute) }),
|
) =>
|
||||||
|
callback({
|
||||||
|
add: (definition) => (command = definition.execute),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: async (name: string, callback: (event: Event) => Promise<void> | void) => {
|
hook: async (name: string, callback: (event: Event) => Promise<void> | void) => {
|
||||||
@@ -106,7 +109,11 @@ async function host() {
|
|||||||
: { id: sessionID, agent: "build" },
|
: { id: sessionID, agent: "build" },
|
||||||
create: async (input: { parentID: string; model: object }) => {
|
create: async (input: { parentID: string; model: object }) => {
|
||||||
expect(input.parentID).toBe("ses_root");
|
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" };
|
return { id: "ses_worker" };
|
||||||
},
|
},
|
||||||
generate: async ({ sessionID, prompt }: { sessionID: string; prompt: string }) => {
|
generate: async ({ sessionID, prompt }: { sessionID: string; prompt: string }) => {
|
||||||
@@ -116,7 +123,10 @@ async function host() {
|
|||||||
agent: "build",
|
agent: "build",
|
||||||
system: [{ type: "text", text: "host system" }],
|
system: [{ type: "text", text: "host system" }],
|
||||||
messages: [
|
messages: [
|
||||||
new Entry({ role: "user", content: [{ type: "text", text: prompt }] }),
|
new Entry({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: prompt }],
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
tools: {},
|
tools: {},
|
||||||
};
|
};
|
||||||
@@ -137,7 +147,11 @@ async function host() {
|
|||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
system: [
|
system: [
|
||||||
{ type: "text", text: "host one", cache: { type: "ephemeral" } },
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "host one",
|
||||||
|
cache: { type: "ephemeral" },
|
||||||
|
},
|
||||||
{ type: "text", text: "host two" },
|
{ type: "text", text: "host two" },
|
||||||
],
|
],
|
||||||
messages: messages.map((message) => new Entry(message)),
|
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 () => {
|
test("turns see the view and the new message; compactions retry Too long and build the tree", async () => {
|
||||||
const h = await host();
|
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");
|
expect(h.notices.at(-1)).toStartWith("[optchat] on. 0 messages");
|
||||||
const long = "please remember: " + "detail ".repeat(100);
|
const long = "please remember: " + "detail ".repeat(100);
|
||||||
h.replies.push("x".repeat(700), "y".repeat(600), "z".repeat(400));
|
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);
|
expect(first.messages[1]!.content[0]!.text).toBe(long);
|
||||||
// System start and end, and the request's end; no whole view block yet.
|
// System start and end, and the request's end; no whole view block yet.
|
||||||
expect(hints(first)).toBe(3);
|
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();
|
await settle();
|
||||||
|
|
||||||
// The compaction ran in the worker with the same system prompt, retried twice, and kept the shortest line.
|
// 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",
|
role: "assistant",
|
||||||
content: [
|
content: [
|
||||||
{ type: "reasoning", text: "never logged" },
|
{ 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.
|
// 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"]);
|
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",
|
role: "assistant",
|
||||||
content: [
|
content: [
|
||||||
{ type: "reasoning", text: "" },
|
{ 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"),
|
content: message.content.filter((part) => part.type !== "reasoning"),
|
||||||
}));
|
}));
|
||||||
await h.turn("ses_root", [
|
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,
|
...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" }] },
|
{ id: "m6", role: "user", content: [{ type: "text", text: "again" }] },
|
||||||
]);
|
]);
|
||||||
await settle();
|
await settle();
|
||||||
@@ -378,62 +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 () => {
|
test("without compactionModel, optchat registers nothing", async () => {
|
||||||
// Regression: system-role messages before the turn start were dropped.
|
const options = { compactionModel: "", dataDir: "" };
|
||||||
const h = await host();
|
const registered: string[] = [];
|
||||||
await h.command()({ sessionID: "ses_root", prompt: { text: "on" } });
|
const record = (name: string) => async () => {
|
||||||
const event = await h.turn("ses_root", [
|
registered.push(name);
|
||||||
{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] },
|
return { dispose: async () => {} };
|
||||||
{ id: "m2", role: "assistant", content: [{ type: "text", text: "hello" }] },
|
};
|
||||||
{ id: "m3", role: "system", content: [{ type: "text", text: "Instructions updated" }] },
|
const context = {
|
||||||
{ id: "m4", role: "user", content: [{ type: "text", text: "next" }] },
|
options,
|
||||||
]);
|
tool: { transform: record("tool") },
|
||||||
expect(event.messages.map((message) => message.role)).toEqual(["user", "system", "user"]);
|
command: { transform: record("command") },
|
||||||
expect(event.messages[1]!.content[0]!.text).toBe("Instructions updated");
|
session: { hook: record("hook") },
|
||||||
if (h.cleanup) await h.cleanup();
|
};
|
||||||
});
|
// SAFETY: setup must return before touching any host service; a call is recorded and fails the test.
|
||||||
|
const setup = optchat.setup as (
|
||||||
test("checkpoints are not logged, provider-executed calls end a turn, and empty replies stop retries", async () => {
|
context: Pick<Plugin.Context, "options">,
|
||||||
const h = await host();
|
) => ReturnType<typeof optchat.setup>;
|
||||||
await h.command()({ sessionID: "ses_root", prompt: { text: "on" } });
|
expect(await setup(context)).toBeUndefined();
|
||||||
h.replies.push("");
|
expect(registered).toEqual([]);
|
||||||
const event = await h.turn("ses_root", [
|
|
||||||
{
|
|
||||||
id: "m1",
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "<conversation-checkpoint>old</conversation-checkpoint>" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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([
|
|
||||||
"<chat>\n0+1|tool: web_search {}\n1+1|(not summarized yet: zoom it)\n</chat>",
|
|
||||||
"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",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+38
-123
@@ -8,7 +8,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types";
|
|||||||
import type { Skill } from "@opencode/schema/skill";
|
import type { Skill } from "@opencode/schema/skill";
|
||||||
import type { ModelInfo } from "@opencode/client";
|
import type { ModelInfo } from "@opencode/client";
|
||||||
import plugin from "../src/agents.js";
|
import plugin from "../src/agents.js";
|
||||||
import { settings, gptAgents, workerNames } from "../src/config.js";
|
import { model } from "../src/model.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AgentsRpc } from "../src/agents-rpc.js";
|
import { AgentsRpc } from "../src/agents-rpc.js";
|
||||||
|
|
||||||
@@ -23,9 +23,17 @@ const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({
|
|||||||
settings: { reasoningEffort: effort },
|
settings: { reasoningEffort: effort },
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
const agents = {
|
||||||
|
"fast-lo": "test/gpt-6-luna#low",
|
||||||
|
"fast-hi": "test/gpt-6-luna#high",
|
||||||
|
"good-lo": "test/gpt-6.1-sol#low",
|
||||||
|
"good-hi": "test/gpt-6.1-sol#high",
|
||||||
|
"deep-lo": "test/gpt-6-astra#low",
|
||||||
|
"deep-hi": "test/gpt-6-astra#high",
|
||||||
|
};
|
||||||
async function host(
|
async function host(
|
||||||
catalog: ModelInfo[] = models,
|
catalog: ModelInfo[] = models,
|
||||||
options: Plugin.Context["options"] = { provider: "test" },
|
options: Plugin.Context["options"] = { agents },
|
||||||
existingSkill?: Skill.Info,
|
existingSkill?: Skill.Info,
|
||||||
) {
|
) {
|
||||||
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
||||||
@@ -221,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, { provider: "test" }, original);
|
|
||||||
expect(h.skills.get("anti-overengineering")).toEqual(original);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("worker availability, deep fallback, and model refresh remain provider aware", async () => {
|
test("worker availability, deep fallback, and model refresh remain provider aware", async () => {
|
||||||
const h = await host();
|
const h = await host();
|
||||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
||||||
@@ -250,67 +242,41 @@ test("worker availability, deep fallback, and model refresh remain provider awar
|
|||||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("custom models use provider-qualified names and optional deep assignments", async () => {
|
test("without agents options or with the empty skeleton nothing is registered", async () => {
|
||||||
const assignments = Object.fromEntries(
|
const skeleton = Object.fromEntries(Object.keys(agents).map((name) => [name, ""]));
|
||||||
workerNames
|
for (const options of [{}, { agents: skeleton }]) {
|
||||||
.filter((name) => !name.startsWith("deep-"))
|
const h = await host(models, options);
|
||||||
.map((name) => [name, { ...gptAgents[name], model: `test/${gptAgents[name].model}` }]),
|
expect([...h.agents.keys()]).toEqual(["build"]);
|
||||||
|
expect(h.skills.size).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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-")),
|
||||||
);
|
);
|
||||||
const duplicate = models.map((model) => ({ ...model, providerID: Provider.ID.make("other") }));
|
const duplicate = models.map((model) => ({ ...model, providerID: Provider.ID.make("other") }));
|
||||||
const h = await host([...duplicate, ...models], { mode: "custom", agents: assignments });
|
const h = await host([...duplicate, ...models], { agents: shallow });
|
||||||
expect(String(h.agents.get("coordinate")?.model?.providerID)).toBe("test");
|
expect(String(h.agents.get("coordinate")?.model?.providerID)).toBe("test");
|
||||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
|
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
|
||||||
expect(h.agents.has("deep-hi")).toBe(false);
|
expect(h.agents.has("deep-hi")).toBe(false);
|
||||||
expect(settings.safeParse({ mode: "gpt", provider: "test", lunaPriority: true }).success).toBe(
|
expect(model("a/b/c#high", "n")).toEqual({ providerID: "a", id: "b/c", variant: "high" });
|
||||||
false,
|
expect(model("a/b", "n")).toEqual({ providerID: "a", id: "b" });
|
||||||
|
expect(() => model("unqualified#low", "agents fast-lo")).toThrow(
|
||||||
|
"agents fast-lo must be provider/model[#variant]: unqualified#low",
|
||||||
);
|
);
|
||||||
expect(
|
await expect(
|
||||||
settings.safeParse({
|
host(models, { agents: { ...agents, "fast-lo": "unqualified#low" } }),
|
||||||
mode: "custom",
|
).rejects.toThrow("agents fast-lo must be provider/model[#variant]");
|
||||||
agents: { ...assignments, "fast-lo": { model: "unqualified", effort: "low" } },
|
const empty = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } });
|
||||||
}).success,
|
expect(String(empty.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
|
||||||
).toBe(false);
|
expect(empty.agents.has("deep-lo")).toBe(false);
|
||||||
});
|
await expect(host(models, { agents: { ...agents, "good-hi": "" } })).rejects.toThrow(
|
||||||
|
"agents good-hi is required",
|
||||||
test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => {
|
);
|
||||||
const h = await host();
|
await expect(host(models, { agents: { "deep-lo": agents["deep-lo"] } })).rejects.toThrow(
|
||||||
const rules = [
|
"agents fast-lo is required",
|
||||||
{ 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,
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
test("native tool and command paths retain core delegation restrictions", async () => {
|
||||||
@@ -331,54 +297,3 @@ test("native tool and command paths retain core delegation restrictions", async
|
|||||||
.parse((await h.request("coordinate")).subagent?.input.properties).agent,
|
.parse((await h.request("coordinate")).subagent?.input.properties).agent,
|
||||||
).toEqual({ type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] });
|
).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("Luna priority requires official or advertised support and can be disabled", async () => {
|
|
||||||
const openai = models.map((model) => ({
|
|
||||||
...model,
|
|
||||||
providerID: Provider.ID.make("openai"),
|
|
||||||
package: "@opencode/ai/providers/openai",
|
|
||||||
}));
|
|
||||||
const official = await host(openai, { provider: "openai" });
|
|
||||||
expect(official.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("priority");
|
|
||||||
expect(official.agents.get("good-lo")?.request?.settings.serviceTier).toBeUndefined();
|
|
||||||
const disabled = await host(openai, { provider: "openai", lunaPriority: false });
|
|
||||||
expect(disabled.agents.get("fast-hi")?.request?.settings.serviceTier).toBeUndefined();
|
|
||||||
const proxy = openai.map((model) => ({
|
|
||||||
...model,
|
|
||||||
settings: { baseURL: "https://proxy.example/v1" },
|
|
||||||
}));
|
|
||||||
const unadvertised = await host(proxy, { provider: "openai" });
|
|
||||||
expect(unadvertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBeUndefined();
|
|
||||||
const advertised = await host(
|
|
||||||
proxy.map((model) => ({ ...model, settings: { ...model.settings, serviceTier: "fast" } })),
|
|
||||||
{ provider: "openai" },
|
|
||||||
);
|
|
||||||
expect(advertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("fast");
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import type { Plugin } from "@opencode/plugin";
|
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 quota from "../src/quota-plugin.js";
|
||||||
import multiWork from "../src/multi-work.js";
|
|
||||||
import { QuotaRpc } from "../src/quota-rpc.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 () => {
|
test("Quota loads independently and retains its RPC ID and nullable status", async () => {
|
||||||
let controller: ReadableStreamDefaultController<unknown>;
|
let controller: ReadableStreamDefaultController<unknown>;
|
||||||
@@ -65,20 +61,3 @@ test("Quota loads independently and retains its RPC ID and nullable status", asy
|
|||||||
if (cleanup) await cleanup();
|
if (cleanup) await cleanup();
|
||||||
expect(disposed).toBe(true);
|
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<Context, "options">,
|
|
||||||
) => ReturnType<typeof multiWork.effect>;
|
|
||||||
const result = await Effect.runPromiseExit(
|
|
||||||
Effect.scoped(activate({ ...context, options: {} })),
|
|
||||||
);
|
|
||||||
expect(String(result)).toContain("Multi-work requires Agents loaded before it");
|
|
||||||
});
|
|
||||||
|
|||||||
+4
-276
@@ -1,7 +1,6 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import type { Schema } from "effect";
|
import type { Schema } from "effect";
|
||||||
import { normalizeQuota, QuotaTracker } from "../src/quota.js";
|
import { normalizeQuota, QuotaTracker } from "../src/quota.js";
|
||||||
import { formatQuotaWindow, loadQuotaStatus } from "../src/tui.js";
|
|
||||||
|
|
||||||
const plus = {
|
const plus = {
|
||||||
plan_type: "plus",
|
plan_type: "plus",
|
||||||
@@ -24,7 +23,8 @@ const plus = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
test("normalizes representative Plus and Pro payloads and hides absent windows", () => {
|
test("normalizes Plus and Pro payloads, hiding absent windows and excluded Spark and reserve limits", () => {
|
||||||
|
{
|
||||||
expect(normalizeQuota(plus, 123)).toEqual({
|
expect(normalizeQuota(plus, 123)).toEqual({
|
||||||
plan: "plus",
|
plan: "plus",
|
||||||
fetchedAt: 123,
|
fetchedAt: 123,
|
||||||
@@ -61,41 +61,8 @@ test("normalizes representative Plus and Pro payloads and hides absent windows",
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(normalizeQuota({ plan_type: "plus", rate_limit: null })).toBeUndefined();
|
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,
|
|
||||||
additional_rate_limits: [
|
|
||||||
{
|
|
||||||
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({
|
const normalized = normalizeQuota({
|
||||||
...plus,
|
...plus,
|
||||||
additional_rate_limits: [
|
additional_rate_limits: [
|
||||||
@@ -139,21 +106,7 @@ test("ignores Spark and representative reserve limits while preserving other quo
|
|||||||
"GPT-5.6 Luna",
|
"GPT-5.6 Luna",
|
||||||
"Preserved capacity",
|
"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",
|
|
||||||
rate_limit: { primary_window: plus.rate_limit.primary_window },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function fixture(
|
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 () => {
|
test("activates only for OAuth and refreshes quota at initialization and request start", async () => {
|
||||||
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
||||||
await key.tracker.init();
|
await key.tracker.init();
|
||||||
@@ -347,124 +234,6 @@ test("activates only for OAuth and refreshes quota at initialization and request
|
|||||||
await oauth.tracker.dispose();
|
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 () => {
|
test("an auth switch detaches an in-flight old-account response and immediately fetches the new account", async () => {
|
||||||
const pending = Promise.withResolvers<unknown>();
|
const pending = Promise.withResolvers<unknown>();
|
||||||
const second = {
|
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");
|
expect(result.tracker.status()?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||||
await result.tracker.dispose();
|
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<Schema.Json | undefined>();
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test";
|
|||||||
import type { Plugin } from "@opencode/plugin/tui";
|
import type { Plugin } from "@opencode/plugin/tui";
|
||||||
import type { Route, SlotMap } from "@opencode/plugin/tui/context";
|
import type { Route, SlotMap } from "@opencode/plugin/tui/context";
|
||||||
import { testRender, type JSX } from "@opentui/solid";
|
import { testRender, type JSX } from "@opentui/solid";
|
||||||
import { jsx } from "@opentui/solid/jsx-runtime";
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { build } from "../scripts/build.js";
|
import { build } from "../scripts/build.js";
|
||||||
import type { QuotaStatus } from "../src/quota.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();
|
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user