Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f956effff | ||
|
|
dadcf69327 | ||
|
|
ff027ef6ac | ||
|
|
80728e7d75 | ||
|
|
a183975b8a | ||
|
|
52e98c9d78 | ||
|
|
8b6836f5a4 |
@@ -40,22 +40,24 @@ Both installers use `~/.config/opencode`, respect `XDG_CONFIG_HOME`, and accept
|
|||||||
|
|
||||||
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc).
|
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc).
|
||||||
|
|
||||||
For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. Set `lunaPriority` to `false` to avoid requesting priority pricing.
|
For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. In GPT mode with the `openai` provider, set top-level `lunaPriority` to `false` to avoid requesting priority pricing. Other providers do not accept this option. Custom top-level and per-agent `provider` and `lunaPriority` fields are invalid.
|
||||||
Custom configurations require the six worker assignments. The legacy optional `coordinate`
|
Custom configurations require the six worker assignments and use `provider/model` model names.
|
||||||
assignment is accepted for compatibility but deprecated and ignored; orchestration inherits
|
The legacy optional `coordinate` assignment is accepted for compatibility but ignored. Orchestration
|
||||||
`deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable.
|
inherits `deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable.
|
||||||
|
|
||||||
## Multi-work
|
## Multi-work
|
||||||
|
|
||||||
Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval
|
Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval
|
||||||
to `fast-lo` and independent work to `coordinate` children in new Herdr worktrees.
|
to `fast-lo` and independent work to `coordinate` children in either the current root
|
||||||
|
checkout or new Herdr worktrees.
|
||||||
It is primary-only and, like `coordinate`, uses the `deep-lo` model and effort,
|
It is primary-only and, like `coordinate`, uses the `deep-lo` model and effort,
|
||||||
falling back to `good-lo` when `deep-lo` is unavailable. Clients viewing the
|
falling back to `good-lo` when `deep-lo` is unavailable. Clients viewing the
|
||||||
same eligible location can see it; eligibility is revalidated before creation.
|
same eligible location can see it; eligibility is revalidated before creation.
|
||||||
|
|
||||||
Configure `experimental.subagent_depth: 2` at the root and worktree locations.
|
Configure `experimental.subagent_depth: 2` at the root and worktree locations.
|
||||||
The native `subagent` tool accepts `worktree: { base: "source-branch" }` for new
|
For new coordinate tasks, omit `worktree` to share the current root checkout, or pass
|
||||||
coordinate tasks and retains native session/job handling. See [usage, permissions,
|
`worktree: { base: "source-branch" }` for an isolated checkout. The tool retains native
|
||||||
|
session/job handling. See [usage, permissions,
|
||||||
failure handling, and verification limits](docs/multi-work.md).
|
failure handling, and verification limits](docs/multi-work.md).
|
||||||
|
|
||||||
## Playwright
|
## Playwright
|
||||||
|
|||||||
+37
-13
@@ -1,13 +1,15 @@
|
|||||||
# Multi-work
|
# Multi-work
|
||||||
|
|
||||||
`multi-work` is a **primary-only** agent for coordinating independent work in
|
`multi-work` is a **primary-only** agent for coordinating independent work in
|
||||||
Herdr-managed Git worktrees. Like `coordinate`, it uses the configured `deep-lo`
|
the current Herdr root checkout or new Herdr-managed Git worktrees. Like
|
||||||
model and effort, falling back to `good-lo` when `deep-lo` is unavailable. No
|
`coordinate`, it uses the configured `deep-lo` model and effort, falling back
|
||||||
extra custom model assignment is required.
|
to `good-lo` when `deep-lo` is unavailable. No extra custom model assignment is
|
||||||
|
required.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
multi-work (root checkout)
|
multi-work (root checkout)
|
||||||
├── fast-lo (local retrieval)
|
├── fast-lo (local retrieval)
|
||||||
|
├── coordinate (current root checkout)
|
||||||
└── coordinate (new Herdr worktree)
|
└── coordinate (new Herdr worktree)
|
||||||
└── existing leaf workers (that worktree)
|
└── existing leaf workers (that worktree)
|
||||||
```
|
```
|
||||||
@@ -56,7 +58,24 @@ plugins or project configuration.
|
|||||||
|
|
||||||
## Delegation
|
## Delegation
|
||||||
|
|
||||||
Multi-work retains the native tool name, `subagent`. It adds one argument:
|
Multi-work retains the native tool name, `subagent`. For work in the current root
|
||||||
|
checkout, omit `worktree`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
subagent({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "Implement local change",
|
||||||
|
prompt: "Goal, owned files, constraints, and acceptance checks...",
|
||||||
|
background: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The child is bound to the verified root workspace before its prompt is admitted.
|
||||||
|
No Herdr workspace is created and neither the child nor parent is moved. Root-local
|
||||||
|
children share a checkout, so parallel assignments must have disjoint file ownership.
|
||||||
|
Their placement metadata uses `branch: "root"` to identify the shared root checkout;
|
||||||
|
it is not a claim about the root's current Git branch.
|
||||||
|
Use a worktree when edits may overlap or require branch isolation:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
subagent({
|
subagent({
|
||||||
@@ -86,17 +105,22 @@ checkout path; the tool parses and verifies the returned checkout/workspace.
|
|||||||
Creation explicitly preserves focus with `--no-focus`.
|
Creation explicitly preserves focus with `--no-focus`.
|
||||||
|
|
||||||
For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no
|
For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no
|
||||||
`worktree`. Resume an owned coordinate child with its `sessionID` and **omit**
|
`worktree`. A new coordinate call without `worktree` means root-local work. Resume
|
||||||
`worktree`. Resumes verify parent, agent, checkout, branch, and live Herdr ownership.
|
an owned coordinate child with its `sessionID` and **omit**
|
||||||
|
`worktree`. Resumes verify parent, agent, assigned checkout, and live Herdr ownership;
|
||||||
|
worktree resumes also verify the branch.
|
||||||
Bound coordinate children cannot be repurposed through native delegation.
|
Bound coordinate children cannot be repurposed through native delegation.
|
||||||
|
|
||||||
Neither multi-work nor coordinate has general shell/edit permissions. Multi-work
|
Neither multi-work nor coordinate has general shell/edit permissions. Multi-work
|
||||||
also rejects non-reading/non-delegation tools at execution, including Code Mode,
|
also rejects non-reading/non-delegation tools at execution, including Code Mode,
|
||||||
even if a session permission override would expose them. Worktree preparation
|
even if a session permission override would expose them. Creating or resuming
|
||||||
requires effective `herdr_worktree: allow`, evaluated against the canonical root
|
worktree-backed delegation requires effective `herdr_worktree: allow`, evaluated
|
||||||
directory using ordered agent and session rules. `ask` fails explicitly because
|
against the canonical root directory using ordered agent and session rules. `ask`
|
||||||
the public plugin API cannot request custom-tool approval. Native subagent
|
fails explicitly because the public plugin API cannot request custom-tool approval.
|
||||||
permissions are still asserted by OpenCode before preparation starts.
|
Native subagent permissions are still asserted by OpenCode before preparation
|
||||||
|
starts. Root-local delegation performs no Herdr creation and therefore does not
|
||||||
|
require `herdr_worktree`; worker edit/shell permissions remain inherited and
|
||||||
|
enforced normally.
|
||||||
|
|
||||||
Worktree permissions inherit the base plugin's **effective build-permission
|
Worktree permissions inherit the base plugin's **effective build-permission
|
||||||
snapshot**, including configuration applied after plugin transforms. Its existing
|
snapshot**, including configuration applied after plugin transforms. Its existing
|
||||||
@@ -118,8 +142,8 @@ child ID, and confirm its actual session location before allowing the task promp
|
|||||||
A move-request acknowledgement alone is insufficient. Confirmation is bounded to
|
A move-request acknowledgement alone is insufficient. Confirmation is bounded to
|
||||||
30 seconds and remains interruptible. The parent session does not move.
|
30 seconds and remains interruptible. The parent session does not move.
|
||||||
|
|
||||||
Coordinate is child-capable only through an owned, ready worktree binding. Prompt
|
Coordinate is child-capable only through an owned, ready multi-work binding. Prompt
|
||||||
admission and model-context guards reject unbound or moved coordinate children.
|
admission and model-context guards reject unbound or relocated coordinate children.
|
||||||
Multi-work cannot be a child, and the existing workers remain leaves.
|
Multi-work cannot be a child, and the existing workers remain leaves.
|
||||||
|
|
||||||
Normal delegation cards and child navigation recognize the retained `subagent`
|
Normal delegation cards and child navigation recognize the retained `subagent`
|
||||||
|
|||||||
@@ -3,22 +3,20 @@
|
|||||||
// Required for multi-work -> coordinate -> workers. Apply in worktrees too.
|
// Required for multi-work -> coordinate -> workers. Apply in worktrees too.
|
||||||
// Multi-work and coordinate use deep-lo, falling back to good-lo when unavailable.
|
// Multi-work and coordinate use deep-lo, falling back to good-lo when unavailable.
|
||||||
"experimental": { "subagent_depth": 2 },
|
"experimental": { "subagent_depth": 2 },
|
||||||
// All six worker assignments are required. Model IDs are local to the provider.
|
// All six worker assignments are required. Models use provider/model names.
|
||||||
// An unavailable deep-lo omits deep workers and uses good-lo for orchestration.
|
// An unavailable deep-lo omits deep workers and uses good-lo for orchestration.
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"package": "stanp-opencode",
|
"package": "stanp-opencode",
|
||||||
"options": {
|
"options": {
|
||||||
"mode": "custom",
|
"mode": "custom",
|
||||||
"provider": "openai",
|
|
||||||
"lunaPriority": false,
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"fast-lo": { "model": "gpt-5.6-luna", "effort": "low" },
|
"fast-lo": { "model": "openai/gpt-6-luna", "effort": "low" },
|
||||||
"fast-hi": { "model": "gpt-5.6-luna", "effort": "high" },
|
"fast-hi": { "model": "openai/gpt-6-luna", "effort": "high" },
|
||||||
"good-lo": { "model": "gpt-5.6-sol", "effort": "low" },
|
"good-lo": { "model": "openai/gpt-6-sol", "effort": "low" },
|
||||||
"good-hi": { "model": "gpt-5.6-sol", "effort": "high" },
|
"good-hi": { "model": "openai/gpt-6-sol", "effort": "high" },
|
||||||
"deep-lo": { "model": "gpt-6-astra", "effort": "low" },
|
"deep-lo": { "model": "openai/gpt-6-astra", "effort": "low" },
|
||||||
"deep-hi": { "model": "gpt-6-astra", "effort": "high" },
|
"deep-hi": { "model": "openai/gpt-6-astra", "effort": "high" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+43
-16
@@ -9,40 +9,67 @@ export const workerNames = [
|
|||||||
"deep-hi",
|
"deep-hi",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const assignment = z.strictObject({
|
const gptAssignment = z.strictObject({
|
||||||
model: z.string().trim().min(1),
|
model: z.string().trim().min(1),
|
||||||
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]),
|
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
|
||||||
const common = {
|
const common = {
|
||||||
provider: z.string().trim().min(1).default("openai"),
|
|
||||||
lunaPriority: z.boolean().default(true),
|
|
||||||
playwright: z.boolean().default(false),
|
playwright: z.boolean().default(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settings = z.discriminatedUnion("mode", [
|
export const settings = z.discriminatedUnion("mode", [
|
||||||
z.strictObject({ ...common, mode: z.literal("gpt") }),
|
z
|
||||||
|
.strictObject({
|
||||||
|
...common,
|
||||||
|
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({
|
z.strictObject({
|
||||||
...common,
|
...common,
|
||||||
mode: z.literal("custom"),
|
mode: z.literal("custom"),
|
||||||
agents: z.strictObject({
|
agents: z.strictObject({
|
||||||
"fast-lo": assignment,
|
"fast-lo": customAssignment,
|
||||||
"fast-hi": assignment,
|
"fast-hi": customAssignment,
|
||||||
"good-lo": assignment,
|
"good-lo": customAssignment,
|
||||||
"good-hi": assignment,
|
"good-hi": customAssignment,
|
||||||
"deep-lo": assignment,
|
"deep-lo": customAssignment,
|
||||||
"deep-hi": assignment,
|
"deep-hi": customAssignment,
|
||||||
// Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo.
|
// Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo.
|
||||||
coordinate: assignment.optional(),
|
coordinate: customAssignment.optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const gptAgents = {
|
export const gptAgents = {
|
||||||
"fast-lo": { model: "gpt-5.6-luna", effort: "low" },
|
"fast-lo": { model: "gpt-6-luna", effort: "low" },
|
||||||
"fast-hi": { model: "gpt-5.6-luna", effort: "high" },
|
"fast-hi": { model: "gpt-6-luna", effort: "high" },
|
||||||
"good-lo": { model: "gpt-5.6-sol", effort: "low" },
|
"good-lo": { model: "gpt-6-sol", effort: "low" },
|
||||||
"good-hi": { model: "gpt-5.6-sol", effort: "high" },
|
"good-hi": { model: "gpt-6-sol", effort: "high" },
|
||||||
"deep-lo": { model: "gpt-6-astra", effort: "low" },
|
"deep-lo": { model: "gpt-6-astra", effort: "low" },
|
||||||
"deep-hi": { model: "gpt-6-astra", effort: "high" },
|
"deep-hi": { model: "gpt-6-astra", effort: "high" },
|
||||||
} satisfies Record<(typeof workerNames)[number], z.infer<typeof assignment>>;
|
} satisfies Record<(typeof workerNames)[number], z.infer<typeof gptAssignment>>;
|
||||||
|
|||||||
+43
-11
@@ -3,7 +3,7 @@ import type { RpcRegistration } from "@opencode/plugin/promise/rpc";
|
|||||||
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 { gptAgents, settings, workerNames } from "./config.js";
|
||||||
import { discover } from "./provider.js";
|
import { discover, modelKey } from "./provider.js";
|
||||||
import { descriptions, routingGuide } from "./routing.js";
|
import { descriptions, routingGuide } from "./routing.js";
|
||||||
import { QuotaTracker } from "./quota.js";
|
import { QuotaTracker } from "./quota.js";
|
||||||
import { QuotaRpc } from "./quota-rpc.js";
|
import { QuotaRpc } from "./quota-rpc.js";
|
||||||
@@ -50,6 +50,10 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
id: "stanp-opencode",
|
id: "stanp-opencode",
|
||||||
async setup(context) {
|
async setup(context) {
|
||||||
const selected = settings.parse({ mode: "gpt", ...context.options });
|
const selected = settings.parse({ mode: "gpt", ...context.options });
|
||||||
|
const lunaPriority =
|
||||||
|
selected.mode === "gpt" &&
|
||||||
|
selected.provider === "openai" &&
|
||||||
|
selected.lunaPriority === true;
|
||||||
let models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
|
let models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
|
||||||
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
||||||
Agent.ID.make("build"),
|
Agent.ID.make("build"),
|
||||||
@@ -83,7 +87,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (event.type === "model.updated") {
|
if (event.type === "model.updated") {
|
||||||
models = await discover(context, selected.provider);
|
models = await discover(context, lunaPriority);
|
||||||
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
|
||||||
@@ -118,13 +122,24 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
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, selected.provider);
|
models = await discover(context, lunaPriority);
|
||||||
const { data: initialAgents } = await context.agent.list();
|
const { data: initialAgents } = await context.agent.list();
|
||||||
inherited =
|
inherited =
|
||||||
initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||||
permissionsChanged(inherited);
|
permissionsChanged(inherited);
|
||||||
permissionKey = JSON.stringify(inherited);
|
permissionKey = JSON.stringify(inherited);
|
||||||
const assignments = selected.mode === "gpt" ? gptAgents : selected.agents;
|
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>();
|
||||||
const playwrightCommand = selected.playwright
|
const playwrightCommand = selected.playwright
|
||||||
@@ -163,11 +178,26 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.agent.transform((editor) => {
|
await context.agent.transform((editor) => {
|
||||||
const deep = models.has(assignments["deep-lo"].model);
|
const coordinates = (assignment: { model: string }) => {
|
||||||
|
if (selected.mode === "gpt")
|
||||||
|
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: (typeof assignments)[(typeof workerNames)[number]],
|
||||||
|
) => {
|
||||||
|
const { provider, model } = coordinates(assignment);
|
||||||
|
return models.get(modelKey(provider, model));
|
||||||
|
};
|
||||||
|
const deep = availableFor(assignments["deep-lo"]) !== undefined;
|
||||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||||
const coordinate = assignments[deep ? "deep-lo" : "good-lo"];
|
const coordinate = assignments[deep ? "deep-lo" : "good-lo"];
|
||||||
workers = [coordinate, ...candidates.map((name) => assignments[name])].every(
|
workers = [coordinate, ...candidates.map((name) => assignments[name])].every(
|
||||||
({ model }) => models.has(model),
|
(assignment) => availableFor(assignment) !== undefined,
|
||||||
)
|
)
|
||||||
? candidates
|
? candidates
|
||||||
: [];
|
: [];
|
||||||
@@ -176,7 +206,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
if (workers.length === 0) return;
|
if (workers.length === 0) return;
|
||||||
for (const name of workers) {
|
for (const name of workers) {
|
||||||
const assignment = assignments[name];
|
const assignment = assignments[name];
|
||||||
const available = models.get(assignment.model)!;
|
const available = availableFor(assignment)!;
|
||||||
editor.update(name, (agent) => {
|
editor.update(name, (agent) => {
|
||||||
const hasVariant = available.model.variants.some(
|
const hasVariant = available.model.variants.some(
|
||||||
({ id }) => id === assignment.effort,
|
({ id }) => id === assignment.effort,
|
||||||
@@ -196,7 +226,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
};
|
};
|
||||||
if (selected.lunaPriority && available.serviceTier)
|
if (lunaPriority && available.serviceTier)
|
||||||
agent.request.settings.serviceTier = available.serviceTier;
|
agent.request.settings.serviceTier = available.serviceTier;
|
||||||
agent.permissions = [
|
agent.permissions = [
|
||||||
...structuredClone(inherited),
|
...structuredClone(inherited),
|
||||||
@@ -205,7 +235,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
editor.update("coordinate", (agent) => {
|
editor.update("coordinate", (agent) => {
|
||||||
const available = models.get(coordinate.model)!;
|
const available = availableFor(coordinate)!;
|
||||||
const hasVariant = available.model.variants.some(
|
const hasVariant = available.model.variants.some(
|
||||||
({ id }) => id === coordinate.effort,
|
({ id }) => id === coordinate.effort,
|
||||||
);
|
);
|
||||||
@@ -224,6 +254,8 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
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" },
|
||||||
@@ -320,7 +352,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
if (agent === "multi-work") throw new Error("multi-work is primary only");
|
if (agent === "multi-work") throw new Error("multi-work is primary only");
|
||||||
if (agent === "coordinate" && event.agent !== "multi-work")
|
if (agent === "coordinate" && event.agent !== "multi-work")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"coordinate cannot run as a subagent without a ready worktree binding",
|
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||||
);
|
);
|
||||||
if (workerNames.some((name) => name === event.agent))
|
if (workerNames.some((name) => name === event.agent))
|
||||||
throw new Error("Workers cannot delegate tasks");
|
throw new Error("Workers cannot delegate tasks");
|
||||||
@@ -349,7 +381,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
|||||||
parent.location.directory !== binding.data.root.directory
|
parent.location.directory !== binding.data.root.directory
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"coordinate cannot run as a subagent without a ready worktree binding",
|
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
+65
-29
@@ -9,7 +9,8 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { Herdr, rootSchema } from "./herdr.js";
|
import { Herdr, rootSchema } from "./herdr.js";
|
||||||
import { multiWorkGuide } from "./routing.js";
|
import { multiWorkGuide } from "./routing.js";
|
||||||
|
|
||||||
export const bindingSchema = z.object({
|
export const bindingSchema = z
|
||||||
|
.object({
|
||||||
owner: z.string(),
|
owner: z.string(),
|
||||||
child: z.string(),
|
child: z.string(),
|
||||||
projectID: z.string(),
|
projectID: z.string(),
|
||||||
@@ -18,7 +19,14 @@ export const bindingSchema = z.object({
|
|||||||
workspaceID: z.string(),
|
workspaceID: z.string(),
|
||||||
branch: z.string(),
|
branch: z.string(),
|
||||||
ready: z.boolean(),
|
ready: z.boolean(),
|
||||||
});
|
})
|
||||||
|
.refine(
|
||||||
|
(binding) =>
|
||||||
|
binding.directory === binding.root.directory
|
||||||
|
? binding.workspaceID === binding.root.workspaceID && binding.branch === "root"
|
||||||
|
: binding.workspaceID !== binding.root.workspaceID,
|
||||||
|
{ message: "Invalid multi-work placement metadata" },
|
||||||
|
);
|
||||||
export const bindingKey = (id: string) => `multi-work:${id}`;
|
export const bindingKey = (id: string) => `multi-work:${id}`;
|
||||||
const inputSchema = z
|
const inputSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -98,7 +106,7 @@ export const installMultiWork = (
|
|||||||
agent.name = Agent.Name.make("multi-work");
|
agent.name = Agent.Name.make("multi-work");
|
||||||
agent.mode = "primary";
|
agent.mode = "primary";
|
||||||
agent.description =
|
agent.description =
|
||||||
"Coordinate independent tasks in Herdr worktrees; retrieve local facts with fast-lo.";
|
"Coordinate independent tasks locally or in Herdr worktrees; retrieve local facts with fast-lo.";
|
||||||
agent.system = multiWorkGuide;
|
agent.system = multiWorkGuide;
|
||||||
agent.permissions = [
|
agent.permissions = [
|
||||||
...coordinate.permissions.filter((rule) => rule.action !== "subagent"),
|
...coordinate.permissions.filter((rule) => rule.action !== "subagent"),
|
||||||
@@ -137,14 +145,14 @@ export const installMultiWork = (
|
|||||||
child.projectID !== binding.projectID ||
|
child.projectID !== binding.projectID ||
|
||||||
child.location.directory !== binding.directory
|
child.location.directory !== binding.directory
|
||||||
)
|
)
|
||||||
throw new Error("Coordinate child does not match its ready worktree binding");
|
throw new Error("Coordinate child does not match its ready multi-work binding");
|
||||||
const parent = yield* context.session.get({ sessionID: child.parentID });
|
const parent = yield* context.session.get({ sessionID: child.parentID });
|
||||||
if (
|
if (
|
||||||
parent.agent !== "multi-work" ||
|
parent.agent !== "multi-work" ||
|
||||||
parent.projectID !== binding.projectID ||
|
parent.projectID !== binding.projectID ||
|
||||||
parent.location.directory !== binding.root.directory
|
parent.location.directory !== binding.root.directory
|
||||||
)
|
)
|
||||||
throw new Error("Worktree owner changed agent or location");
|
throw new Error("Binding owner changed agent or location");
|
||||||
return binding;
|
return binding;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,7 +192,7 @@ export const installMultiWork = (
|
|||||||
agent: { type: "string", enum: ["fast-lo", "coordinate"] },
|
agent: { type: "string", enum: ["fast-lo", "coordinate"] },
|
||||||
};
|
};
|
||||||
tool.description =
|
tool.description =
|
||||||
"Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base, branch?, label?}. Prefer descriptive human-readable branch and label values, for example {base:'main',branch:'fix/login-timeout',label:'Fix login timeout'}. Continue an owned coordinate child with sessionID and no worktree argument; naming fields are create-only. Native background and results apply.";
|
"Delegate to fast-lo for retrieval, or coordinate in this root checkout by omitting worktree. To isolate new coordinate work, use worktree: {base, branch?, label?}. Continue an owned coordinate child with sessionID and no worktree argument. Native background and results apply.";
|
||||||
}
|
}
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(Effect.orDie),
|
||||||
);
|
);
|
||||||
@@ -208,9 +216,7 @@ export const installMultiWork = (
|
|||||||
throw new Error("A bound coordinate child cannot be repurposed");
|
throw new Error("A bound coordinate child cannot be repurposed");
|
||||||
if (call.agent !== "multi-work") {
|
if (call.agent !== "multi-work") {
|
||||||
if (input.worktree || input.agent === "coordinate")
|
if (input.worktree || input.agent === "coordinate")
|
||||||
throw new Error(
|
throw new Error("Only multi-work may delegate to coordinate");
|
||||||
"Only multi-work may initialize coordinate worktrees",
|
|
||||||
);
|
|
||||||
return yield* native.execute(input, call);
|
return yield* native.execute(input, call);
|
||||||
}
|
}
|
||||||
const parent = yield* context.session.get({ sessionID: call.sessionID });
|
const parent = yield* context.session.get({ sessionID: call.sessionID });
|
||||||
@@ -240,14 +246,27 @@ export const installMultiWork = (
|
|||||||
}
|
}
|
||||||
if (input.agent !== "coordinate")
|
if (input.agent !== "coordinate")
|
||||||
throw new Error("multi-work delegates only to fast-lo or coordinate");
|
throw new Error("multi-work delegates only to fast-lo or coordinate");
|
||||||
if (
|
if (input.sessionID && input.worktree !== undefined)
|
||||||
input.sessionID
|
|
||||||
? input.worktree !== undefined
|
|
||||||
: input.worktree === undefined
|
|
||||||
)
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"New coordinate work requires worktree: {base}; resumes require only sessionID",
|
"Coordinate resumes require only sessionID; worktree is create-only",
|
||||||
);
|
);
|
||||||
|
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`;
|
||||||
|
if (busy.has(lock)) throw new Error("This delegation is already active");
|
||||||
|
busy.add(lock);
|
||||||
|
let childLock: string | undefined;
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
let initialized = false;
|
||||||
|
let placement: z.infer<typeof bindingSchema> | undefined;
|
||||||
|
if (input.sessionID) {
|
||||||
|
const binding = yield* validate(Session.ID.make(input.sessionID));
|
||||||
|
if (binding.owner !== call.sessionID)
|
||||||
|
throw new Error("Cannot resume another parent's binding");
|
||||||
|
placement = binding;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.worktree ||
|
||||||
|
(placement && placement.directory !== placement.root.directory)
|
||||||
|
) {
|
||||||
const { data: agent } = yield* context.agent.get({
|
const { data: agent } = yield* context.agent.get({
|
||||||
agentID: call.agent,
|
agentID: call.agent,
|
||||||
location: { directory: parent.location.directory },
|
location: { directory: parent.location.directory },
|
||||||
@@ -264,17 +283,16 @@ export const installMultiWork = (
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`,
|
`herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`,
|
||||||
);
|
);
|
||||||
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`;
|
}
|
||||||
if (busy.has(lock)) throw new Error("This delegation is already active");
|
if (placement) {
|
||||||
busy.add(lock);
|
const binding = placement;
|
||||||
let childLock: string | undefined;
|
if (
|
||||||
return yield* Effect.gen(function* () {
|
root.directory !== binding.root.directory ||
|
||||||
let initialized = false;
|
root.workspaceID !== binding.root.workspaceID ||
|
||||||
let placement: z.infer<typeof bindingSchema> | undefined;
|
root.repo !== binding.root.repo
|
||||||
if (input.sessionID) {
|
)
|
||||||
const binding = yield* validate(Session.ID.make(input.sessionID));
|
throw new Error("Bound Herdr root is no longer available");
|
||||||
if (binding.owner !== call.sessionID)
|
if (binding.directory !== binding.root.directory)
|
||||||
throw new Error("Cannot resume another parent's worktree");
|
|
||||||
yield* Effect.tryPromise((signal) =>
|
yield* Effect.tryPromise((signal) =>
|
||||||
herdr.verify(
|
herdr.verify(
|
||||||
binding.directory,
|
binding.directory,
|
||||||
@@ -284,7 +302,6 @@ export const installMultiWork = (
|
|||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
placement = binding;
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
}
|
}
|
||||||
const { worktree, ...args } = input;
|
const { worktree, ...args } = input;
|
||||||
@@ -310,8 +327,6 @@ export const installMultiWork = (
|
|||||||
);
|
);
|
||||||
childLock = id;
|
childLock = id;
|
||||||
busy.add(id);
|
busy.add(id);
|
||||||
if (!worktree)
|
|
||||||
throw new Error("Missing worktree initialization");
|
|
||||||
const sessionID = Session.ID.make(id);
|
const sessionID = Session.ID.make(id);
|
||||||
const child = yield* context.session.get({ sessionID });
|
const child = yield* context.session.get({ sessionID });
|
||||||
if (
|
if (
|
||||||
@@ -323,6 +338,27 @@ export const installMultiWork = (
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"Native executor returned an unexpected child",
|
"Native executor returned an unexpected child",
|
||||||
);
|
);
|
||||||
|
if (!worktree) {
|
||||||
|
const binding = {
|
||||||
|
owner: call.sessionID,
|
||||||
|
child: id,
|
||||||
|
projectID: parent.projectID,
|
||||||
|
root,
|
||||||
|
directory: root.directory,
|
||||||
|
workspaceID: root.workspaceID,
|
||||||
|
branch: "root",
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
yield* context.storage.set(callKey, {
|
||||||
|
child: id,
|
||||||
|
branch: binding.branch,
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
yield* context.storage.set(bindingKey(id), binding);
|
||||||
|
placement = binding;
|
||||||
|
initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const branch =
|
const branch =
|
||||||
worktree.branch ?? `multi-work/${randomUUID()}`;
|
worktree.branch ?? `multi-work/${randomUUID()}`;
|
||||||
yield* context.storage.set(callKey, {
|
yield* context.storage.set(callKey, {
|
||||||
|
|||||||
+11
-4
@@ -3,7 +3,7 @@ import type { ModelInfo } from "@opencode/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
function priority(model: ModelInfo) {
|
function priority(model: ModelInfo) {
|
||||||
if (model.modelID !== "gpt-5.6-luna") return undefined;
|
if (model.modelID !== "gpt-6-luna") return undefined;
|
||||||
for (const settings of [model.settings, ...model.variants.map((variant) => variant.settings)]) {
|
for (const settings of [model.settings, ...model.variants.map((variant) => variant.settings)]) {
|
||||||
if (settings?.serviceTier === "priority" || settings?.serviceTier === "fast")
|
if (settings?.serviceTier === "priority" || settings?.serviceTier === "fast")
|
||||||
return settings.serviceTier;
|
return settings.serviceTier;
|
||||||
@@ -20,13 +20,20 @@ function priority(model: ModelInfo) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discover(context: Plugin.Context, providerID: string) {
|
export function modelKey(providerID: string, modelID: string) {
|
||||||
|
return `${providerID}\0${modelID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discover(context: Plugin.Context, detectPriority: boolean) {
|
||||||
// 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, { model: ModelInfo; serviceTier: string | undefined }>();
|
||||||
for (const model of data) {
|
for (const model of data) {
|
||||||
if (model.providerID === providerID && model.enabled)
|
if (model.enabled)
|
||||||
models.set(model.id, { model, serviceTier: priority(model) });
|
models.set(modelKey(model.providerID, model.id), {
|
||||||
|
model,
|
||||||
|
serviceTier: detectPriority ? priority(model) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-6
@@ -16,15 +16,17 @@ export const descriptions = {
|
|||||||
export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout.
|
export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout.
|
||||||
Read, plan, track todos and delegate; never implement or execute shell commands yourself.
|
Read, plan, track todos and delegate; never implement or execute shell commands yourself.
|
||||||
Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate
|
Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate
|
||||||
for work in a new Herdr worktree, supplying descriptive human-readable names such as
|
without worktree to edit concurrently in this root checkout, or isolate work in a new
|
||||||
|
Herdr worktree by supplying descriptive human-readable names such as
|
||||||
worktree: {base: "main", branch: "fix/login-timeout", label: "Fix login timeout"}, plus
|
worktree: {base: "main", branch: "fix/login-timeout", label: "Fix login timeout"}, plus
|
||||||
description and a complete prompt with scope, constraints and acceptance checks. The tool
|
description and a complete prompt with scope, constraints and acceptance checks. The tool
|
||||||
uses the requested branch and Herdr workspace label; when omitted, it creates the prior unique
|
uses the requested branch and Herdr workspace label; when the branch is omitted, it creates the prior unique
|
||||||
multi-work/<uuid> branch and lets Herdr choose its default label. It moves the coordinate child
|
multi-work/<uuid> branch and lets Herdr choose its default label. It moves the coordinate child
|
||||||
before its task begins, and preserves your root session. Dirty root changes are not copied.
|
before its task begins, and preserves your root session. Dirty root changes are not copied.
|
||||||
Resume an owned coordinate child using its sessionID and no worktree argument. Never switch
|
Resume an owned coordinate child using its sessionID and no worktree argument. Never switch
|
||||||
its agent or location; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
|
its agent or assigned checkout; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
|
||||||
Parallelize independent work with disjoint ownership; serialize dependencies. Native background
|
Root-local workers share one checkout, so give them disjoint file ownership and never assign
|
||||||
|
overlapping edits concurrently. Parallelize independent work; serialize dependencies. Native background
|
||||||
delegation returns immediately and notifies you when complete; do not poll or duplicate work.
|
delegation returns immediately and notifies you when complete; do not poll or duplicate work.
|
||||||
Reconcile results and report checkout, branch and child IDs. Retained resources on failure
|
Reconcile results and report checkout, branch and child IDs. Retained resources on failure
|
||||||
must not be deleted automatically. Never retry ambiguous creation failures blindly.
|
must not be deleted automatically. Never retry ambiguous creation failures blindly.
|
||||||
@@ -33,8 +35,8 @@ load this plugin, its worker models and experimental.subagent_depth >= 2. If una
|
|||||||
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:
|
// Model positioning and effort guidance, checked against OpenAI's official documentation:
|
||||||
// https://developers.openai.com/api/docs/models/gpt-5.6-luna
|
// https://developers.openai.com/api/docs/models/gpt-6-luna
|
||||||
// https://developers.openai.com/api/docs/models/gpt-5.6-sol
|
// https://developers.openai.com/api/docs/models/gpt-6-sol
|
||||||
// https://developers.openai.com/api/docs/models/gpt-6-astra
|
// https://developers.openai.com/api/docs/models/gpt-6-astra
|
||||||
// https://developers.openai.com/api/docs/guides/reasoning
|
// https://developers.openai.com/api/docs/guides/reasoning
|
||||||
export function routingGuide(deep: boolean) {
|
export function routingGuide(deep: boolean) {
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ export default Plugin.define({
|
|||||||
return (
|
return (
|
||||||
<Show when={status()}>
|
<Show when={status()}>
|
||||||
<Show when={props.centered} fallback={<Content />}>
|
<Show when={props.centered} fallback={<Content />}>
|
||||||
<box width="100%" alignItems="center">
|
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
|
||||||
<Content />
|
<Content />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
|
|||||||
const store = (key: string) =>
|
const store = (key: string) =>
|
||||||
join(fixture.directory, "store", `${encodeURIComponent(key)}.json`);
|
join(fixture.directory, "store", `${encodeURIComponent(key)}.json`);
|
||||||
const providerID = Provider.ID.make("fixture");
|
const providerID = Provider.ID.make("fixture");
|
||||||
const models = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) => ({
|
const models = ["gpt-6-luna", "gpt-6-sol", "gpt-6-astra"].map((id) => ({
|
||||||
...Model.Info.default(providerID, Model.ID.make(id)),
|
...Model.Info.default(providerID, Model.ID.make(id)),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Vendored
+1
-1
@@ -37,7 +37,7 @@ export default Plugin.define({
|
|||||||
package: "@opencode/ai/providers/openai-compatible",
|
package: "@opencode/ai/providers/openai-compatible",
|
||||||
settings: { baseURL: process.env.FIXTURE_MODEL_URL! },
|
settings: { baseURL: process.env.FIXTURE_MODEL_URL! },
|
||||||
},
|
},
|
||||||
models: ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) =>
|
models: ["gpt-6-luna", "gpt-6-sol", "gpt-6-astra"].map((id) =>
|
||||||
Model.Info.default(providerID, Model.ID.make(id)),
|
Model.Info.default(providerID, Model.ID.make(id)),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+151
-1
@@ -14,6 +14,8 @@ import { bindingKey, bindingSchema, installMultiWork } from "../src/multi-work.j
|
|||||||
|
|
||||||
class FixtureHerdr extends Herdr {
|
class FixtureHerdr extends Herdr {
|
||||||
eligible = true;
|
eligible = true;
|
||||||
|
rootWorkspaceID = "w1";
|
||||||
|
rootRepo = "/root/.git";
|
||||||
creations = 0;
|
creations = 0;
|
||||||
verified = 0;
|
verified = 0;
|
||||||
delayCreation = false;
|
delayCreation = false;
|
||||||
@@ -24,7 +26,7 @@ class FixtureHerdr extends Herdr {
|
|||||||
creationStarted = Promise.withResolvers<void>();
|
creationStarted = Promise.withResolvers<void>();
|
||||||
override async root(directory: string) {
|
override async root(directory: string) {
|
||||||
return this.eligible && directory === "/root"
|
return this.eligible && directory === "/root"
|
||||||
? { directory, workspaceID: "w1", repo: "/root/.git" }
|
? { directory, workspaceID: this.rootWorkspaceID, repo: this.rootRepo }
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
override async create(
|
override async create(
|
||||||
@@ -366,6 +368,128 @@ test("local fast-lo uses native delegation without a worktree", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coordinate can create and resume a root-local background child without Herdr creation or movement", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.parent.permissions = [{ action: "herdr_worktree", resource: "*", effect: "deny" }];
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* f.install;
|
||||||
|
const created = yield* f.execute({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "local edit",
|
||||||
|
prompt: "edit assigned files",
|
||||||
|
background: true,
|
||||||
|
});
|
||||||
|
expect(created.metadata).toMatchObject({
|
||||||
|
sessionID: "ses_child1",
|
||||||
|
status: "running",
|
||||||
|
directory: "/root",
|
||||||
|
workspaceID: "w1",
|
||||||
|
branch: "root",
|
||||||
|
});
|
||||||
|
expect(f.herdr.creations).toBe(0);
|
||||||
|
expect(f.order).toEqual(["create", "prompt:/root"]);
|
||||||
|
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1")))).toMatchObject({
|
||||||
|
owner: "ses_parent",
|
||||||
|
directory: "/root",
|
||||||
|
workspaceID: "w1",
|
||||||
|
branch: "root",
|
||||||
|
ready: true,
|
||||||
|
});
|
||||||
|
yield* f.execute({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "resume local",
|
||||||
|
prompt: "continue",
|
||||||
|
sessionID: "ses_child1",
|
||||||
|
});
|
||||||
|
expect(f.herdr.creations).toBe(0);
|
||||||
|
expect(f.herdr.verified).toBe(0);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("root-local resume rejects location, binding, and live-root changes", async () => {
|
||||||
|
for (const tamper of ["session", "workspace", "root", "branch", "live-root"] as const) {
|
||||||
|
const f = fixture();
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* f.install;
|
||||||
|
yield* f.execute({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "local edit",
|
||||||
|
prompt: "work",
|
||||||
|
});
|
||||||
|
if (tamper === "session")
|
||||||
|
f.sessions.get("ses_child1")!.location.directory = "/other";
|
||||||
|
else if (tamper === "live-root") f.herdr.rootWorkspaceID = "foreign";
|
||||||
|
else {
|
||||||
|
const binding = bindingSchema.parse(
|
||||||
|
f.storage.get(bindingKey("ses_child1")),
|
||||||
|
);
|
||||||
|
const changed = { ...binding };
|
||||||
|
if (tamper === "workspace") changed.workspaceID = "foreign";
|
||||||
|
if (tamper === "root") {
|
||||||
|
changed.root = { ...binding.root, workspaceID: "foreign" };
|
||||||
|
changed.workspaceID = "foreign";
|
||||||
|
}
|
||||||
|
if (tamper === "branch") changed.branch = "main";
|
||||||
|
f.storage.set(bindingKey("ses_child1"), changed);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(yield* Effect.exit(
|
||||||
|
f.execute({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "resume",
|
||||||
|
prompt: "continue",
|
||||||
|
sessionID: "ses_child1",
|
||||||
|
}),
|
||||||
|
))._tag,
|
||||||
|
).toBe("Failure");
|
||||||
|
expect(f.prompts).toBe(1);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interrupted root-local creation retains its binding and cannot be blindly replayed", 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.execute({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "local edit",
|
||||||
|
prompt: "work",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
await f.entered.promise;
|
||||||
|
controller.abort();
|
||||||
|
await expect(running).rejects.toBeDefined();
|
||||||
|
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(true);
|
||||||
|
expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true);
|
||||||
|
const replay = await Effect.runPromise(
|
||||||
|
Effect.exit(
|
||||||
|
f.execute({
|
||||||
|
agent: "coordinate",
|
||||||
|
description: "local edit",
|
||||||
|
prompt: "work",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(replay._tag).toBe("Failure");
|
||||||
|
expect(f.prompts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("custom branch and workspace label pass through delegation", async () => {
|
test("custom branch and workspace label pass through delegation", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
@@ -623,6 +747,32 @@ test("configured build restrictions remain effective for worktree preparation",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("failed initialization records the attempt and never blindly recreates on replay", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
f.missingDestination();
|
f.missingDestination();
|
||||||
|
|||||||
+193
-43
@@ -9,7 +9,7 @@ import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/too
|
|||||||
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
||||||
import type { SessionContext } from "@opencode/plugin/promise/session";
|
import type { SessionContext } from "@opencode/plugin/promise/session";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { gptAgents } from "../src/config.js";
|
import { gptAgents, settings, workerNames } from "../src/config.js";
|
||||||
import { basePlugin as plugin } from "../src/index.js";
|
import { basePlugin as plugin } from "../src/index.js";
|
||||||
import { playwrightMcpCommand } from "../src/playwright.js";
|
import { playwrightMcpCommand } from "../src/playwright.js";
|
||||||
import { bindingKey } from "../src/multi-work.js";
|
import { bindingKey } from "../src/multi-work.js";
|
||||||
@@ -27,7 +27,31 @@ function reasoningModel(id: string): ModelInfo {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const allModels = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map(reasoningModel);
|
function providerModel(provider: string, id: string): ModelInfo {
|
||||||
|
return {
|
||||||
|
...reasoningModel(id),
|
||||||
|
providerID: Provider.ID.make(provider),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const allModels = ["gpt-6-luna", "gpt-6-sol", "gpt-6-astra"].map(reasoningModel);
|
||||||
|
type CustomAssignment = {
|
||||||
|
model: string;
|
||||||
|
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||||
|
};
|
||||||
|
const customAssignments = (provider = "test") => {
|
||||||
|
const assignment = (name: (typeof workerNames)[number]): CustomAssignment => ({
|
||||||
|
...gptAgents[name],
|
||||||
|
model: `${provider}/${gptAgents[name].model}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
"fast-lo": assignment("fast-lo"),
|
||||||
|
"fast-hi": assignment("fast-hi"),
|
||||||
|
"good-lo": assignment("good-lo"),
|
||||||
|
"good-hi": assignment("good-hi"),
|
||||||
|
"deep-lo": assignment("deep-lo"),
|
||||||
|
"deep-hi": assignment("deep-hi"),
|
||||||
|
};
|
||||||
|
};
|
||||||
const cleanups: Plugin.Cleanup[] = [];
|
const cleanups: Plugin.Cleanup[] = [];
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const cleanup of cleanups.splice(0)) await cleanup();
|
for (const cleanup of cleanups.splice(0)) await cleanup();
|
||||||
@@ -306,17 +330,17 @@ async function host(
|
|||||||
child = target;
|
child = target;
|
||||||
for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" });
|
for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" });
|
||||||
},
|
},
|
||||||
bindCoordinate(ready: boolean, moved = true) {
|
bindCoordinate(ready: boolean, moved = true, local = false) {
|
||||||
childDirectory = moved ? `${directory}/worktree` : directory;
|
childDirectory = local ? directory : moved ? `${directory}/worktree` : directory;
|
||||||
storage.set(bindingKey("child"), {
|
storage.set(bindingKey("child"), {
|
||||||
owner: "parent",
|
owner: "parent",
|
||||||
child: "child",
|
child: "child",
|
||||||
projectID: "project-fixture",
|
projectID: "project-fixture",
|
||||||
ready,
|
ready,
|
||||||
root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
|
root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
|
||||||
directory: `${directory}/worktree`,
|
directory: local ? directory : `${directory}/worktree`,
|
||||||
workspaceID: "w2",
|
workspaceID: local ? "w1" : "w2",
|
||||||
branch: "multi-work/test",
|
branch: local ? "root" : "multi-work/test",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async tools(caller: string) {
|
async tools(caller: string) {
|
||||||
@@ -345,22 +369,27 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo
|
|||||||
expect(String(full.agents.get("coordinate")?.model?.variant)).toBe("low");
|
expect(String(full.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||||
const reduced = await host(allModels.slice(0, 2));
|
const reduced = await host(allModels.slice(0, 2));
|
||||||
expect(reduced.agents.has("deep-hi")).toBe(false);
|
expect(reduced.agents.has("deep-hi")).toBe(false);
|
||||||
expect(String(reduced.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
|
expect(String(reduced.agents.get("coordinate")?.model?.id)).toBe("gpt-6-sol");
|
||||||
expect(String(reduced.agents.get("coordinate")?.model?.variant)).toBe("low");
|
expect(String(reduced.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||||
expect(reduced.agents.get("coordinate")?.system).toContain("Deep agents are unavailable");
|
expect(reduced.agents.get("coordinate")?.system).toContain("Deep agents are unavailable");
|
||||||
expect((await host([])).agents.has("coordinate")).toBe(false);
|
expect((await host([])).agents.has("coordinate")).toBe(false);
|
||||||
expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false);
|
expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false);
|
||||||
|
|
||||||
const customAgents = {
|
const customAgents = {
|
||||||
...gptAgents,
|
...customAssignments(),
|
||||||
"good-lo": { model: "custom-good", effort: "high" as const },
|
"good-lo": {
|
||||||
"deep-lo": { model: "custom-deep", effort: "medium" as const },
|
model: "test/custom-good",
|
||||||
|
effort: "high" as const,
|
||||||
|
},
|
||||||
|
"deep-lo": {
|
||||||
|
model: "test/custom-deep",
|
||||||
|
effort: "medium" as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const custom = await host(
|
const custom = await host(
|
||||||
[...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")],
|
[...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")],
|
||||||
{
|
{
|
||||||
mode: "custom",
|
mode: "custom",
|
||||||
provider: "test",
|
|
||||||
agents: customAgents,
|
agents: customAgents,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -370,7 +399,6 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo
|
|||||||
});
|
});
|
||||||
const customFallback = await host([...allModels, reasoningModel("custom-good")], {
|
const customFallback = await host([...allModels, reasoningModel("custom-good")], {
|
||||||
mode: "custom",
|
mode: "custom",
|
||||||
provider: "test",
|
|
||||||
agents: customAgents,
|
agents: customAgents,
|
||||||
});
|
});
|
||||||
expect(customFallback.agents.get("coordinate")?.model).toMatchObject({
|
expect(customFallback.agents.get("coordinate")?.model).toMatchObject({
|
||||||
@@ -382,13 +410,106 @@ test("orchestration inherits deep-lo and falls back to good-lo with available wo
|
|||||||
(
|
(
|
||||||
await host(allModels, {
|
await host(allModels, {
|
||||||
mode: "custom",
|
mode: "custom",
|
||||||
provider: "test",
|
|
||||||
agents: customAgents,
|
agents: customAgents,
|
||||||
})
|
})
|
||||||
).agents.has("coordinate"),
|
).agents.has("coordinate"),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("custom assignments resolve duplicate model IDs by provider and refresh provider-aware fallback", async () => {
|
||||||
|
const agents = customAssignments("alpha");
|
||||||
|
agents["fast-lo"] = { ...agents["fast-lo"], model: "beta/shared" };
|
||||||
|
agents["fast-hi"] = { ...agents["fast-hi"], model: "alpha/shared" };
|
||||||
|
agents["deep-lo"] = { ...agents["deep-lo"], model: "beta/deep" };
|
||||||
|
agents["deep-hi"] = { ...agents["deep-hi"], model: "beta/deep" };
|
||||||
|
const base = [
|
||||||
|
providerModel("alpha", "shared"),
|
||||||
|
providerModel("beta", "shared"),
|
||||||
|
providerModel("alpha", "gpt-6-sol"),
|
||||||
|
providerModel("alpha", "gpt-6-astra"),
|
||||||
|
];
|
||||||
|
const result = await host(base, { mode: "custom", agents });
|
||||||
|
expect(String(result.agents.get("fast-lo")?.model?.providerID)).toBe("beta");
|
||||||
|
expect(String(result.agents.get("fast-hi")?.model?.providerID)).toBe("alpha");
|
||||||
|
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-sol");
|
||||||
|
expect(result.agents.has("deep-lo")).toBe(false);
|
||||||
|
|
||||||
|
await result.refresh([...base, providerModel("beta", "deep")]);
|
||||||
|
expect(result.agents.get("coordinate")?.model).toMatchObject({
|
||||||
|
providerID: "beta",
|
||||||
|
id: "deep",
|
||||||
|
});
|
||||||
|
expect(result.agents.has("deep-lo")).toBe(true);
|
||||||
|
|
||||||
|
const nested = customAssignments("alpha");
|
||||||
|
nested["fast-lo"] = { ...nested["fast-lo"], model: "alpha/group/shared" };
|
||||||
|
const nestedResult = await host(
|
||||||
|
[...base, providerModel("alpha", "gpt-6-luna"), providerModel("alpha", "group/shared")],
|
||||||
|
{ mode: "custom", agents: nested },
|
||||||
|
);
|
||||||
|
expect(String(nestedResult.agents.get("fast-lo")?.model?.id)).toBe("group/shared");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom configuration requires qualified models and rejects obsolete fields", () => {
|
||||||
|
expect(() => settings.parse({ mode: "custom", agents: customAssignments() })).not.toThrow();
|
||||||
|
for (const model of ["bare", "/model", "provider/"])
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({
|
||||||
|
mode: "custom",
|
||||||
|
agents: {
|
||||||
|
...customAssignments(),
|
||||||
|
"fast-lo": { model, effort: "low" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
const nested = customAssignments();
|
||||||
|
nested["fast-lo"] = { model: "provider/group/model", effort: "low" };
|
||||||
|
expect(() => settings.parse({ mode: "custom", agents: nested })).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({ mode: "custom", provider: "test", agents: customAssignments() }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({ mode: "custom", lunaPriority: false, agents: customAssignments() }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({
|
||||||
|
mode: "custom",
|
||||||
|
agents: {
|
||||||
|
...customAssignments(),
|
||||||
|
"fast-lo": { ...customAssignments()["fast-lo"], provider: "test" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({
|
||||||
|
mode: "custom",
|
||||||
|
agents: {
|
||||||
|
...customAssignments(),
|
||||||
|
"fast-lo": { ...customAssignments()["fast-lo"], lunaPriority: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({
|
||||||
|
mode: "custom",
|
||||||
|
agents: { ...customAssignments(), coordinate: customAssignments()["deep-lo"] },
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
for (const obsolete of [{ provider: "test" }, { lunaPriority: true }])
|
||||||
|
expect(() =>
|
||||||
|
settings.parse({
|
||||||
|
mode: "custom",
|
||||||
|
agents: {
|
||||||
|
...customAssignments(),
|
||||||
|
coordinate: {
|
||||||
|
...customAssignments()["deep-lo"],
|
||||||
|
...obsolete,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
test("worker inheritance and coordinator isolation preserve build and default", async () => {
|
test("worker inheritance and coordinator isolation preserve build and default", async () => {
|
||||||
const result = await host();
|
const result = await host();
|
||||||
expect(result.defaultAgent).toBe("build");
|
expect(result.defaultAgent).toBe("build");
|
||||||
@@ -663,22 +784,25 @@ test("manual calls and command-created child sessions cannot bypass delegation r
|
|||||||
expect((await result.tools("build")).subagent?.description).toBe("Native subagents");
|
expect((await result.tools("build")).subagent?.description).toBe("Native subagents");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("coordinate prompt admission requires both owned provenance and a completed move; multi-work cannot be a child", async () => {
|
test("coordinate prompt admission requires owned ready placement; local and moved bindings work", async () => {
|
||||||
const result = await host();
|
const result = await host();
|
||||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||||
"ready worktree binding",
|
"ready multi-work binding",
|
||||||
);
|
);
|
||||||
result.bindCoordinate(false);
|
result.bindCoordinate(false);
|
||||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||||
"ready worktree binding",
|
"ready multi-work binding",
|
||||||
);
|
);
|
||||||
result.bindCoordinate(true, false);
|
result.bindCoordinate(true, false);
|
||||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||||
"ready worktree binding",
|
"ready multi-work binding",
|
||||||
);
|
);
|
||||||
result.bindCoordinate(true);
|
result.bindCoordinate(true);
|
||||||
await result.command("multi-work", "coordinate");
|
await result.command("multi-work", "coordinate");
|
||||||
await expect(result.command("build", "coordinate")).rejects.toThrow("ready worktree binding");
|
result.bindCoordinate(true, true, true);
|
||||||
|
await result.command("multi-work", "coordinate");
|
||||||
|
await expect(result.command("build", "coordinate")).rejects.toThrow("ready multi-work binding");
|
||||||
|
result.bindCoordinate(true);
|
||||||
await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
|
await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
|
||||||
for (const invoke of [result.delegate, result.command])
|
for (const invoke of [result.delegate, result.command])
|
||||||
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
|
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
|
||||||
@@ -686,33 +810,57 @@ test("coordinate prompt admission requires both owned provenance and a completed
|
|||||||
|
|
||||||
test("reasoning settings and Luna priority use native advertised or official support", async () => {
|
test("reasoning settings and Luna priority use native advertised or official support", async () => {
|
||||||
const luna = {
|
const luna = {
|
||||||
...model("gpt-5.6-luna"),
|
...providerModel("openai", "gpt-6-luna"),
|
||||||
package: "@opencode/ai/providers/openai",
|
package: "@opencode/ai/providers/openai",
|
||||||
settings: { baseURL: "https://api.openai.com/v1" },
|
settings: { baseURL: "https://api.openai.com/v1" },
|
||||||
};
|
};
|
||||||
const ordinary = await host();
|
const openaiModels = [
|
||||||
|
luna,
|
||||||
|
providerModel("openai", "gpt-6-sol"),
|
||||||
|
providerModel("openai", "gpt-6-astra"),
|
||||||
|
];
|
||||||
|
const ordinary = await host([providerModel("openai", "gpt-6-luna"), ...openaiModels.slice(1)], {
|
||||||
|
provider: "openai",
|
||||||
|
});
|
||||||
expect(ordinary.agents.get("fast-lo")?.request.settings).toEqual({});
|
expect(ordinary.agents.get("fast-lo")?.request.settings).toEqual({});
|
||||||
expect(String(ordinary.agents.get("fast-lo")?.model?.variant)).toBe("low");
|
expect(String(ordinary.agents.get("fast-lo")?.model?.variant)).toBe("low");
|
||||||
const official = await host([luna, ...allModels.slice(1)]);
|
const official = await host(openaiModels, { provider: "openai" });
|
||||||
expect(official.agents.get("fast-hi")?.request.settings).toEqual({
|
expect(official.agents.get("fast-hi")?.request.settings).toEqual({ serviceTier: "priority" });
|
||||||
reasoningEffort: "high",
|
const proxy = await host(
|
||||||
serviceTier: "priority",
|
[{ ...luna, settings: { baseURL: "https://proxy.example/v1" } }, ...openaiModels.slice(1)],
|
||||||
});
|
{ provider: "openai" },
|
||||||
const proxy = await host([
|
);
|
||||||
{ ...luna, settings: { baseURL: "https://proxy.example/v1" } },
|
expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({});
|
||||||
...allModels.slice(1),
|
const advertised = await host(
|
||||||
]);
|
[
|
||||||
expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" });
|
|
||||||
const advertised = await host([
|
|
||||||
{ ...luna, package: "custom", settings: { serviceTier: "fast" } },
|
{ ...luna, package: "custom", settings: { serviceTier: "fast" } },
|
||||||
...allModels.slice(1),
|
...openaiModels.slice(1),
|
||||||
]);
|
],
|
||||||
|
{ provider: "openai" },
|
||||||
|
);
|
||||||
expect(advertised.agents.get("fast-lo")?.request.settings.serviceTier).toBe("fast");
|
expect(advertised.agents.get("fast-lo")?.request.settings.serviceTier).toBe("fast");
|
||||||
const disabled = await host([luna, ...allModels.slice(1)], {
|
const sdkAlias = await host(
|
||||||
provider: "test",
|
[{ ...luna, package: "@ai-sdk/openai" }, ...openaiModels.slice(1)],
|
||||||
|
{ provider: "openai" },
|
||||||
|
);
|
||||||
|
expect(sdkAlias.agents.get("fast-lo")?.request.settings.serviceTier).toBe("priority");
|
||||||
|
const disabled = await host(openaiModels, {
|
||||||
|
provider: "openai",
|
||||||
lunaPriority: false,
|
lunaPriority: false,
|
||||||
});
|
});
|
||||||
expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" });
|
expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({});
|
||||||
|
|
||||||
|
const custom = customAssignments("openai");
|
||||||
|
const customResult = await host(openaiModels, {
|
||||||
|
mode: "custom",
|
||||||
|
agents: custom,
|
||||||
|
});
|
||||||
|
expect(customResult.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined();
|
||||||
|
expect(customResult.agents.get("fast-hi")?.request.settings.serviceTier).toBeUndefined();
|
||||||
|
|
||||||
|
const other = await host(allModels, { provider: "test" });
|
||||||
|
expect(other.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined();
|
||||||
|
expect(() => settings.parse({ mode: "gpt", provider: "test", lunaPriority: true })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => {
|
test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => {
|
||||||
@@ -727,15 +875,17 @@ test("coordinator uses inherited effort and ignores the legacy custom assignment
|
|||||||
|
|
||||||
const fallback = await host(allModels.slice(0, 2));
|
const fallback = await host(allModels.slice(0, 2));
|
||||||
expect(fallback.agents.get("coordinate")?.model).toMatchObject({
|
expect(fallback.agents.get("coordinate")?.model).toMatchObject({
|
||||||
id: "gpt-5.6-sol",
|
id: "gpt-6-sol",
|
||||||
variant: "low",
|
variant: "low",
|
||||||
});
|
});
|
||||||
expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||||
|
|
||||||
const custom = await host([...allModels, model("local")], {
|
const custom = await host([...allModels, model("local")], {
|
||||||
mode: "custom",
|
mode: "custom",
|
||||||
provider: "test",
|
agents: {
|
||||||
agents: { ...gptAgents, coordinate: { model: "local", effort: "xhigh" } },
|
...customAssignments(),
|
||||||
|
coordinate: { model: "test/local", effort: "xhigh" },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(custom.agents.get("coordinate")?.model).toMatchObject({
|
expect(custom.agents.get("coordinate")?.model).toMatchObject({
|
||||||
id: "gpt-6-astra",
|
id: "gpt-6-astra",
|
||||||
@@ -745,8 +895,8 @@ test("coordinator uses inherited effort and ignores the legacy custom assignment
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("the current host model API filters discovery and reloads changed availability", async () => {
|
test("the current host model API filters discovery and reloads changed availability", async () => {
|
||||||
const disabled = { ...reasoningModel("gpt-5.6-luna"), enabled: false };
|
const disabled = { ...reasoningModel("gpt-6-luna"), enabled: false };
|
||||||
const other = { ...reasoningModel("gpt-5.6-sol"), providerID: Provider.ID.make("other") };
|
const other = { ...reasoningModel("gpt-6-sol"), providerID: Provider.ID.make("other") };
|
||||||
expect((await host([disabled, other, ...allModels.slice(1)])).agents.has("coordinate")).toBe(
|
expect((await host([disabled, other, ...allModels.slice(1)])).agents.has("coordinate")).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -757,7 +907,7 @@ test("the current host model API filters discovery and reloads changed availabil
|
|||||||
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||||
await result.refresh(allModels.slice(0, 2));
|
await result.refresh(allModels.slice(0, 2));
|
||||||
expect(result.agents.has("deep-lo")).toBe(false);
|
expect(result.agents.has("deep-lo")).toBe(false);
|
||||||
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
|
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-sol");
|
||||||
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
|
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
|
||||||
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
||||||
await result.refresh([]);
|
await result.refresh([]);
|
||||||
|
|||||||
+74
-3
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -87,12 +88,19 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
|||||||
const renderHome = homeRender;
|
const renderHome = homeRender;
|
||||||
const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), {
|
const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 5,
|
height: 6,
|
||||||
});
|
});
|
||||||
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
||||||
const homeFrame = home.captureCharFrame();
|
const homeFrame = home.captureCharFrame();
|
||||||
expect(homeFrame).toContain(" ChatGPT quota (home)");
|
expect(homeFrame.split("\n")).toEqual([
|
||||||
expect(homeFrame).toContain(" 5h: 90% · due");
|
" ",
|
||||||
|
" ",
|
||||||
|
" ChatGPT quota (home) ",
|
||||||
|
" 5h: 90% · due ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
expect(statusCalls).toBe(1);
|
expect(statusCalls).toBe(1);
|
||||||
home.renderer.destroy();
|
home.renderer.destroy();
|
||||||
|
|
||||||
@@ -152,3 +160,66 @@ 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