@nebutra/agent-runtime 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +115 -0
- package/.turbo/turbo-test.log +44 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +253 -0
- package/LICENSE +676 -0
- package/README.md +50 -0
- package/dist/adapters/dispatcher-sse.d.ts +68 -0
- package/dist/adapters/dispatcher-sse.js +11 -0
- package/dist/adapters/dispatcher-sse.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mcp-catalog.d.ts +58 -0
- package/dist/adapters/mcp-catalog.js +9 -0
- package/dist/adapters/mcp-catalog.js.map +1 -0
- package/dist/adapters/prisma-rollout.d.ts +60 -0
- package/dist/adapters/prisma-rollout.js +7 -0
- package/dist/adapters/prisma-rollout.js.map +1 -0
- package/dist/chunk-24ZXP7FI.js +93 -0
- package/dist/chunk-24ZXP7FI.js.map +1 -0
- package/dist/chunk-2DA6Q6TN.js +126 -0
- package/dist/chunk-2DA6Q6TN.js.map +1 -0
- package/dist/chunk-37BBB2P2.js +73 -0
- package/dist/chunk-37BBB2P2.js.map +1 -0
- package/dist/chunk-57W3AR43.js +52 -0
- package/dist/chunk-57W3AR43.js.map +1 -0
- package/dist/chunk-5N4644PB.js +67 -0
- package/dist/chunk-5N4644PB.js.map +1 -0
- package/dist/chunk-5YS7WAPS.js +177 -0
- package/dist/chunk-5YS7WAPS.js.map +1 -0
- package/dist/chunk-6EGG2OZC.js +13 -0
- package/dist/chunk-6EGG2OZC.js.map +1 -0
- package/dist/chunk-7BUOF367.js +126 -0
- package/dist/chunk-7BUOF367.js.map +1 -0
- package/dist/chunk-BJBBR3QA.js +121 -0
- package/dist/chunk-BJBBR3QA.js.map +1 -0
- package/dist/chunk-CGRCUKGT.js +73 -0
- package/dist/chunk-CGRCUKGT.js.map +1 -0
- package/dist/chunk-FUG5DT2C.js +75 -0
- package/dist/chunk-FUG5DT2C.js.map +1 -0
- package/dist/chunk-LO24VOA3.js +199 -0
- package/dist/chunk-LO24VOA3.js.map +1 -0
- package/dist/chunk-MUF7ZZTO.js +57 -0
- package/dist/chunk-MUF7ZZTO.js.map +1 -0
- package/dist/chunk-NN7DATXA.js +46 -0
- package/dist/chunk-NN7DATXA.js.map +1 -0
- package/dist/chunk-PGGWSUTM.js +33 -0
- package/dist/chunk-PGGWSUTM.js.map +1 -0
- package/dist/chunk-RDKYDMXT.js +135 -0
- package/dist/chunk-RDKYDMXT.js.map +1 -0
- package/dist/chunk-YYFPDBJG.js +63 -0
- package/dist/chunk-YYFPDBJG.js.map +1 -0
- package/dist/chunk-ZMYX5VBU.js +135 -0
- package/dist/chunk-ZMYX5VBU.js.map +1 -0
- package/dist/chunk-ZTSKS42I.js +131 -0
- package/dist/chunk-ZTSKS42I.js.map +1 -0
- package/dist/commands.d.ts +74 -0
- package/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -0
- package/dist/definitions.d.ts +94 -0
- package/dist/definitions.js +15 -0
- package/dist/definitions.js.map +1 -0
- package/dist/dispatcher.d.ts +50 -0
- package/dist/dispatcher.js +8 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/durable-turn.d.ts +58 -0
- package/dist/durable-turn.js +9 -0
- package/dist/durable-turn.js.map +1 -0
- package/dist/hook-pipeline.d.ts +114 -0
- package/dist/hook-pipeline.js +13 -0
- package/dist/hook-pipeline.js.map +1 -0
- package/dist/index.d.ts +1874 -0
- package/dist/index.js +3117 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +78 -0
- package/dist/loop.js +9 -0
- package/dist/loop.js.map +1 -0
- package/dist/mcp-bridge.d.ts +48 -0
- package/dist/mcp-bridge.js +8 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/model.d.ts +154 -0
- package/dist/model.js +9 -0
- package/dist/model.js.map +1 -0
- package/dist/policy.d.ts +130 -0
- package/dist/policy.js +23 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +170 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rollout-store-persistent.d.ts +48 -0
- package/dist/rollout-store-persistent.js +9 -0
- package/dist/rollout-store-persistent.js.map +1 -0
- package/dist/rollout.d.ts +82 -0
- package/dist/rollout.js +15 -0
- package/dist/rollout.js.map +1 -0
- package/dist/sandbox.d.ts +65 -0
- package/dist/sandbox.js +15 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/skills.d.ts +93 -0
- package/dist/skills.js +10 -0
- package/dist/skills.js.map +1 -0
- package/dist/subagents.d.ts +129 -0
- package/dist/subagents.js +21 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +77 -0
- package/dist/tools.js +9 -0
- package/dist/tools.js.map +1 -0
- package/package.json +74 -0
- package/src/adapters/dispatcher-sse.test.ts +218 -0
- package/src/adapters/dispatcher-sse.ts +222 -0
- package/src/adapters/index.ts +18 -0
- package/src/adapters/mcp-catalog.test.ts +213 -0
- package/src/adapters/mcp-catalog.ts +188 -0
- package/src/adapters/prisma-rollout.test.ts +153 -0
- package/src/adapters/prisma-rollout.ts +104 -0
- package/src/agent-runtime.test.ts +176 -0
- package/src/artifact-stream.test.ts +330 -0
- package/src/artifact-stream.ts +453 -0
- package/src/channel-gateway.test.ts +432 -0
- package/src/channel-gateway.ts +357 -0
- package/src/code-review.test.ts +501 -0
- package/src/code-review.ts +495 -0
- package/src/command-suggestions.test.ts +251 -0
- package/src/command-suggestions.ts +338 -0
- package/src/commands.test.ts +184 -0
- package/src/commands.ts +140 -0
- package/src/commit-message.test.ts +249 -0
- package/src/commit-message.ts +180 -0
- package/src/context-compaction.test.ts +522 -0
- package/src/context-compaction.ts +434 -0
- package/src/definitions.test.ts +78 -0
- package/src/definitions.ts +190 -0
- package/src/deployment-status.test.ts +215 -0
- package/src/deployment-status.ts +227 -0
- package/src/design-context.test.ts +195 -0
- package/src/design-context.ts +198 -0
- package/src/dispatcher.test.ts +234 -0
- package/src/dispatcher.ts +189 -0
- package/src/durable-turn.test.ts +209 -0
- package/src/durable-turn.ts +135 -0
- package/src/edit-planner.test.ts +204 -0
- package/src/edit-planner.ts +325 -0
- package/src/fuzzy-match.test.ts +311 -0
- package/src/fuzzy-match.ts +444 -0
- package/src/hook-pipeline.test.ts +279 -0
- package/src/hook-pipeline.ts +373 -0
- package/src/inbound-admission.test.ts +394 -0
- package/src/inbound-admission.ts +246 -0
- package/src/index.ts +47 -0
- package/src/loop.test.ts +161 -0
- package/src/loop.ts +211 -0
- package/src/mcp-bridge.test.ts +165 -0
- package/src/mcp-bridge.ts +76 -0
- package/src/memory-provider.test.ts +232 -0
- package/src/memory-provider.ts +257 -0
- package/src/model.ts +168 -0
- package/src/permission-ruleset.test.ts +301 -0
- package/src/permission-ruleset.ts +200 -0
- package/src/policy.ts +151 -0
- package/src/project-repo.test.ts +232 -0
- package/src/project-repo.ts +311 -0
- package/src/protocol.ts +159 -0
- package/src/rollout-store-persistent.test.ts +217 -0
- package/src/rollout-store-persistent.ts +166 -0
- package/src/rollout.ts +150 -0
- package/src/sandbox.ts +113 -0
- package/src/session-share.test.ts +360 -0
- package/src/session-share.ts +310 -0
- package/src/skill-distillation.test.ts +177 -0
- package/src/skill-distillation.ts +369 -0
- package/src/skills.test.ts +277 -0
- package/src/skills.ts +255 -0
- package/src/subagents.test.ts +290 -0
- package/src/subagents.ts +332 -0
- package/src/tools.ts +126 -0
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +33 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the slash-command registry — the unified command model.
|
|
3
|
+
*
|
|
4
|
+
* A command IS a Definition. Slash invocation and model invocation are two
|
|
5
|
+
* front-doors to one expansion. Tier precedence + dual gate are delegated to
|
|
6
|
+
* DefinitionResolver (asserted to work through the wrapper). Multi-tenant:
|
|
7
|
+
* tenantId mandatory, fail-closed, cross-tenant impossible.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type { CommandRecord } from "./commands";
|
|
12
|
+
import { CommandRegistry, expandCommand } from "./commands";
|
|
13
|
+
import type { Frontmatter, SourceTier } from "./definitions";
|
|
14
|
+
|
|
15
|
+
function fm(over: Partial<Frontmatter> = {}): Frontmatter {
|
|
16
|
+
return {
|
|
17
|
+
name: over.name ?? "cmd",
|
|
18
|
+
description: "",
|
|
19
|
+
allowedTools: [],
|
|
20
|
+
disallowedTools: [],
|
|
21
|
+
argNames: [],
|
|
22
|
+
modelInvocable: true,
|
|
23
|
+
userInvocable: true,
|
|
24
|
+
executionMode: "inline",
|
|
25
|
+
paths: [],
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rec(over: Partial<CommandRecord> & { slug: string; tenantId: string }): CommandRecord {
|
|
31
|
+
return {
|
|
32
|
+
sourceTier: "workspace" as SourceTier,
|
|
33
|
+
bodyRef: `body:${over.slug}`,
|
|
34
|
+
frontmatter: fm(over.frontmatter),
|
|
35
|
+
...over,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("CommandRegistry — two front-doors to one record", () => {
|
|
40
|
+
it("resolves the same record via both listForUser and listForModel", () => {
|
|
41
|
+
const reg = new CommandRegistry([rec({ slug: "deploy", tenantId: "t1" })]);
|
|
42
|
+
const u = reg.listForUser({ tenantId: "t1" });
|
|
43
|
+
const m = reg.listForModel({ tenantId: "t1" });
|
|
44
|
+
expect(u.map((c) => c.slug)).toEqual(["deploy"]);
|
|
45
|
+
expect(m.map((c) => c.slug)).toEqual(["deploy"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("hides userInvocable:false from listForUser but keeps it in listForModel", () => {
|
|
49
|
+
const reg = new CommandRegistry([
|
|
50
|
+
rec({ slug: "auto-only", tenantId: "t1", frontmatter: fm({ userInvocable: false }) }),
|
|
51
|
+
]);
|
|
52
|
+
expect(reg.listForUser({ tenantId: "t1" })).toHaveLength(0);
|
|
53
|
+
expect(reg.listForModel({ tenantId: "t1" }).map((c) => c.slug)).toEqual(["auto-only"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hides modelInvocable:false from listForModel but keeps it in listForUser", () => {
|
|
57
|
+
const reg = new CommandRegistry([
|
|
58
|
+
rec({ slug: "human-only", tenantId: "t1", frontmatter: fm({ modelInvocable: false }) }),
|
|
59
|
+
]);
|
|
60
|
+
expect(reg.listForModel({ tenantId: "t1" })).toHaveLength(0);
|
|
61
|
+
expect(reg.listForUser({ tenantId: "t1" }).map((c) => c.slug)).toEqual(["human-only"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("recomputes per call so entitlement/flag changes apply live", () => {
|
|
65
|
+
const r = rec({ slug: "gated", tenantId: "t1", availabilityPlans: ["pro"] });
|
|
66
|
+
const reg = new CommandRegistry([r]);
|
|
67
|
+
expect(reg.listForUser({ tenantId: "t1" })).toHaveLength(0);
|
|
68
|
+
expect(reg.listForUser({ tenantId: "t1", plan: "pro" }).map((c) => c.slug)).toEqual(["gated"]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("CommandRegistry — delegated tier precedence + dual gate", () => {
|
|
73
|
+
it("higher tier overrides lower on slug collision (through the wrapper)", () => {
|
|
74
|
+
const reg = new CommandRegistry([
|
|
75
|
+
rec({ slug: "x", tenantId: "t1", sourceTier: "workspace", bodyRef: "low" }),
|
|
76
|
+
rec({ slug: "x", tenantId: "t1", sourceTier: "policy", bodyRef: "high" }),
|
|
77
|
+
]);
|
|
78
|
+
const [c] = reg.listForUser({ tenantId: "t1" });
|
|
79
|
+
expect(c?.bodyRef).toBe("high");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("dual gate: enabled:false suppressed through the wrapper", () => {
|
|
83
|
+
const reg = new CommandRegistry([rec({ slug: "off", tenantId: "t1", enabled: false })]);
|
|
84
|
+
expect(reg.listForUser({ tenantId: "t1" })).toHaveLength(0);
|
|
85
|
+
expect(reg.listForModel({ tenantId: "t1" })).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("resolveOne returns highest-precedence gated record", () => {
|
|
89
|
+
const reg = new CommandRegistry([
|
|
90
|
+
rec({ slug: "y", tenantId: "t1", sourceTier: "bundled", bodyRef: "lo" }),
|
|
91
|
+
rec({ slug: "y", tenantId: "t1", sourceTier: "plugin", bodyRef: "hi" }),
|
|
92
|
+
]);
|
|
93
|
+
expect(reg.resolveOne("y", { tenantId: "t1" })?.bodyRef).toBe("hi");
|
|
94
|
+
expect(reg.resolveOne("missing", { tenantId: "t1" })).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("CommandRegistry — multi-tenant fail-closed", () => {
|
|
99
|
+
it("throws on empty tenantId at every entry point", () => {
|
|
100
|
+
const reg = new CommandRegistry([rec({ slug: "a", tenantId: "t1" })]);
|
|
101
|
+
expect(() => reg.listForUser({ tenantId: "" })).toThrow();
|
|
102
|
+
expect(() => reg.listForModel({ tenantId: "" })).toThrow();
|
|
103
|
+
expect(() => reg.resolveOne("a", { tenantId: "" })).toThrow();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("never leaks another tenant's commands", () => {
|
|
107
|
+
const reg = new CommandRegistry([
|
|
108
|
+
rec({ slug: "secret", tenantId: "t2" }),
|
|
109
|
+
rec({ slug: "mine", tenantId: "t1" }),
|
|
110
|
+
]);
|
|
111
|
+
expect(reg.listForUser({ tenantId: "t1" }).map((c) => c.slug)).toEqual(["mine"]);
|
|
112
|
+
expect(reg.resolveOne("secret", { tenantId: "t1" })).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("expandCommand — pure prompt expansion", () => {
|
|
117
|
+
it("splits positional input into argNames and substitutes into the body", async () => {
|
|
118
|
+
const cmd = rec({
|
|
119
|
+
slug: "greet",
|
|
120
|
+
tenantId: "t1",
|
|
121
|
+
frontmatter: fm({ argNames: ["who", "when"] }),
|
|
122
|
+
});
|
|
123
|
+
const loader = vi.fn(async () => "Hi ${who} at ${when} in ${workspace}");
|
|
124
|
+
const out = await expandCommand(cmd, "alice noon", { workspace: "/repo" }, loader);
|
|
125
|
+
expect(out.contentBlocks).toEqual([{ role: "user", content: "Hi alice at noon in /repo" }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("folds extra positional args into the last declared arg", async () => {
|
|
129
|
+
const cmd = rec({
|
|
130
|
+
slug: "echo",
|
|
131
|
+
tenantId: "t1",
|
|
132
|
+
frontmatter: fm({ argNames: ["first", "rest"] }),
|
|
133
|
+
});
|
|
134
|
+
const loader = vi.fn(async () => "[${first}] [${rest}]");
|
|
135
|
+
const out = await expandCommand(cmd, "one two three four", {}, loader);
|
|
136
|
+
expect(out.contentBlocks[0]?.content).toBe("[one] [two three four]");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("uses injected variables, not host env", async () => {
|
|
140
|
+
process.env.LEAK_SECRET = "should-not-appear";
|
|
141
|
+
const cmd = rec({ slug: "v", tenantId: "t1" });
|
|
142
|
+
const loader = vi.fn(async () => "session=${sessionId} leak=${LEAK_SECRET}");
|
|
143
|
+
const out = await expandCommand(cmd, "", { sessionId: "s-42" }, loader);
|
|
144
|
+
expect(out.contentBlocks[0]?.content).toBe("session=s-42 leak=");
|
|
145
|
+
delete process.env.LEAK_SECRET;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("calls the lazy bodyLoader exactly once", async () => {
|
|
149
|
+
const cmd = rec({ slug: "lazy", tenantId: "t1" });
|
|
150
|
+
const loader = vi.fn(async () => "static body");
|
|
151
|
+
await expandCommand(cmd, "", {}, loader);
|
|
152
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns declared allowedTools, model override, effort and execution mode", async () => {
|
|
156
|
+
const cmd = rec({
|
|
157
|
+
slug: "deploy",
|
|
158
|
+
tenantId: "t1",
|
|
159
|
+
frontmatter: fm({
|
|
160
|
+
allowedTools: ["Bash", "Read"],
|
|
161
|
+
model: "opus",
|
|
162
|
+
effort: "high",
|
|
163
|
+
executionMode: "fork",
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const out = await expandCommand(cmd, "", {}, async () => "go");
|
|
167
|
+
expect(out.allowedTools).toEqual(["Bash", "Read"]);
|
|
168
|
+
expect(out.modelOverride).toBe("opus");
|
|
169
|
+
expect(out.effort).toBe("high");
|
|
170
|
+
expect(out.executionMode).toBe("fork");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("omits modelOverride when frontmatter.model is 'inherit' or unset", async () => {
|
|
174
|
+
const cmd = rec({ slug: "p", tenantId: "t1", frontmatter: fm({ model: "inherit" }) });
|
|
175
|
+
const out = await expandCommand(cmd, "", {}, async () => "x");
|
|
176
|
+
expect(out.modelOverride).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returned contentBlocks array is frozen (immutability)", async () => {
|
|
180
|
+
const cmd = rec({ slug: "f", tenantId: "t1" });
|
|
181
|
+
const out = await expandCommand(cmd, "", {}, async () => "y");
|
|
182
|
+
expect(Object.isFrozen(out.contentBlocks)).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command registry — faithful re-expression of the unified command model.
|
|
3
|
+
*
|
|
4
|
+
* Semantic model:
|
|
5
|
+
* - A command and a skill are the SAME underlying type. `CommandRecord` is just
|
|
6
|
+
* a `Definition`. The only differences are carried in the record itself:
|
|
7
|
+
* `sourceTier`, and the `userInvocable` / `modelInvocable` frontmatter flags.
|
|
8
|
+
* - Slash invocation (a human types `/deploy`) and model invocation (the model
|
|
9
|
+
* auto-triggers) are two FRONT-DOORS to ONE expansion path.
|
|
10
|
+
* - The registry IS a `DefinitionResolver<CommandRecord>` (layered tier merge +
|
|
11
|
+
* precedence + dual gate are provided by definitions.ts — reused, not rebuilt).
|
|
12
|
+
* `CommandRegistry` is a thin wrapper exposing the two front-doors; both
|
|
13
|
+
* recompute per call so entitlement/flag changes apply live.
|
|
14
|
+
*
|
|
15
|
+
* Scope note: the source harness also has `local` / `local-jsx` command kinds
|
|
16
|
+
* that run TUI-side JSX. Those are intentionally DROPPED here — a server runtime
|
|
17
|
+
* only needs the prompt-expansion path. This module models that path only.
|
|
18
|
+
*
|
|
19
|
+
* Multi-tenant: `tenantId` is mandatory at every entry point; an empty tenant
|
|
20
|
+
* throws (fail-closed); cross-tenant resolution is impossible (the underlying
|
|
21
|
+
* resolver rejects foreign-tenant records).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type Definition,
|
|
26
|
+
DefinitionResolver,
|
|
27
|
+
type ResolveContext,
|
|
28
|
+
substituteArguments,
|
|
29
|
+
} from "./definitions";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A command IS a Definition. No new fields — the distinction between "command"
|
|
33
|
+
* and "skill" is purely the `sourceTier` + invocability flags already present.
|
|
34
|
+
*/
|
|
35
|
+
export type CommandRecord = Definition;
|
|
36
|
+
|
|
37
|
+
/** Lazily loads a command's body text (FS / blob / remote — caller's choice). */
|
|
38
|
+
export type BodyLoader = (cmd: CommandRecord) => Promise<string>;
|
|
39
|
+
|
|
40
|
+
/** One content block destined for the model turn. */
|
|
41
|
+
export interface ContentBlock {
|
|
42
|
+
readonly role: "user";
|
|
43
|
+
readonly content: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Result of expanding a command — declarative only; no enforcement here. */
|
|
47
|
+
export interface ExpandedCommand {
|
|
48
|
+
/** Prompt blocks to splice into the turn. */
|
|
49
|
+
readonly contentBlocks: readonly ContentBlock[];
|
|
50
|
+
/** Tools the command self-declares for the permission context. */
|
|
51
|
+
readonly allowedTools: readonly string[];
|
|
52
|
+
/** Model alias to switch to, if the command pins one (not "inherit"). */
|
|
53
|
+
readonly modelOverride?: string | undefined;
|
|
54
|
+
/** Reasoning effort hint, if declared. */
|
|
55
|
+
readonly effort?: "low" | "medium" | "high" | undefined;
|
|
56
|
+
/** inline = expand into current turn; fork = isolated child. */
|
|
57
|
+
readonly executionMode: "inline" | "fork";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Split a positional argument string across declared arg names. Extra tokens
|
|
62
|
+
* beyond the last declared name are folded into that last argument (matches the
|
|
63
|
+
* source's "rest goes to the last positional" behaviour). With no declared
|
|
64
|
+
* names the whole string is exposed as `${ARGUMENTS}` / `$1`.
|
|
65
|
+
*/
|
|
66
|
+
function bindArgs(input: string, argNames: readonly string[]): Record<string, string> {
|
|
67
|
+
const tokens = input.trim().length > 0 ? input.trim().split(/\s+/) : [];
|
|
68
|
+
const args: Record<string, string> = {};
|
|
69
|
+
if (argNames.length === 0) {
|
|
70
|
+
args.ARGUMENTS = input.trim();
|
|
71
|
+
args["1"] = input.trim();
|
|
72
|
+
return args;
|
|
73
|
+
}
|
|
74
|
+
argNames.forEach((name, i) => {
|
|
75
|
+
if (i === argNames.length - 1) {
|
|
76
|
+
args[name] = tokens.slice(i).join(" ");
|
|
77
|
+
} else {
|
|
78
|
+
args[name] = tokens[i] ?? "";
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pure expansion. Loads the body lazily (exactly once), substitutes named args
|
|
86
|
+
* and a tenant-safe variable map (only the explicitly injected `variables` —
|
|
87
|
+
* never `process.env`), and returns the declarative execution context.
|
|
88
|
+
*/
|
|
89
|
+
export async function expandCommand(
|
|
90
|
+
cmd: CommandRecord,
|
|
91
|
+
argInput: string,
|
|
92
|
+
variables: Readonly<Record<string, string>>,
|
|
93
|
+
bodyLoader: BodyLoader,
|
|
94
|
+
): Promise<ExpandedCommand> {
|
|
95
|
+
const body = await bodyLoader(cmd);
|
|
96
|
+
const args = bindArgs(argInput, cmd.frontmatter.argNames);
|
|
97
|
+
// Only injected variables are visible — host env is deliberately excluded.
|
|
98
|
+
const content = substituteArguments(body, args, { ...variables });
|
|
99
|
+
|
|
100
|
+
const model = cmd.frontmatter.model;
|
|
101
|
+
const modelOverride = model && model !== "inherit" ? model : undefined;
|
|
102
|
+
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
contentBlocks: Object.freeze([
|
|
105
|
+
Object.freeze({ role: "user" as const, content }),
|
|
106
|
+
]) as readonly ContentBlock[],
|
|
107
|
+
allowedTools: Object.freeze([...cmd.frontmatter.allowedTools]),
|
|
108
|
+
modelOverride,
|
|
109
|
+
effort: cmd.frontmatter.effort,
|
|
110
|
+
executionMode: cmd.frontmatter.executionMode,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Thin tenant-scoped wrapper over `DefinitionResolver<CommandRecord>`. Exposes
|
|
116
|
+
* the two front-doors. Tier merge / precedence / dual gate / tenant isolation
|
|
117
|
+
* are delegated to the resolver — this class only filters by invocability.
|
|
118
|
+
*/
|
|
119
|
+
export class CommandRegistry {
|
|
120
|
+
readonly #resolver: DefinitionResolver<CommandRecord>;
|
|
121
|
+
|
|
122
|
+
constructor(commands: readonly CommandRecord[]) {
|
|
123
|
+
this.#resolver = new DefinitionResolver<CommandRecord>(commands);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Commands a human may type as a slash command. */
|
|
127
|
+
listForUser(ctx: ResolveContext): CommandRecord[] {
|
|
128
|
+
return this.#resolver.resolve(ctx).filter((c) => c.frontmatter.userInvocable !== false);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Commands the model may auto-trigger. */
|
|
132
|
+
listForModel(ctx: ResolveContext): CommandRecord[] {
|
|
133
|
+
return this.#resolver.resolve(ctx).filter((c) => c.frontmatter.modelInvocable !== false);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Highest-precedence gated record for a slug (no invocability filter). */
|
|
137
|
+
resolveOne(slug: string, ctx: ResolveContext): CommandRecord | undefined {
|
|
138
|
+
return this.#resolver.resolveOne(slug, ctx);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildCommitPrompt,
|
|
4
|
+
CommitMessageError,
|
|
5
|
+
type CompletionModel,
|
|
6
|
+
DEFAULT_ABORT_MS,
|
|
7
|
+
DEFAULT_TEMPERATURE,
|
|
8
|
+
type GitContext,
|
|
9
|
+
generateCommitMessage,
|
|
10
|
+
MAX_RETRIES,
|
|
11
|
+
stripFormatting,
|
|
12
|
+
} from "./commit-message.js";
|
|
13
|
+
|
|
14
|
+
const ctx: GitContext = {
|
|
15
|
+
branch: "feat/access-invites",
|
|
16
|
+
recentCommits: ["feat(web): manage access invites", "chore(web): remove stale helper"],
|
|
17
|
+
files: [
|
|
18
|
+
{ status: "M", path: "apps/web/src/invites.ts", diff: "@@ -1 +1 @@\n-old\n+new" },
|
|
19
|
+
{ status: "A", path: "apps/web/src/invites.test.ts", diff: "@@ +1 @@\n+added" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Deterministic fake model. `script` yields one resolution per `complete()`
|
|
25
|
+
* call; an Error value is thrown (a "rejection"), a string is returned. Records
|
|
26
|
+
* every invocation so prompt/temperature/abort wiring can be asserted.
|
|
27
|
+
*/
|
|
28
|
+
class ScriptedModel implements CompletionModel {
|
|
29
|
+
#i = 0;
|
|
30
|
+
readonly calls: Array<{
|
|
31
|
+
system: string;
|
|
32
|
+
user: string;
|
|
33
|
+
temperature: number;
|
|
34
|
+
abortMs: number;
|
|
35
|
+
}> = [];
|
|
36
|
+
constructor(private readonly script: ReadonlyArray<string | Error>) {}
|
|
37
|
+
async complete(p: {
|
|
38
|
+
system: string;
|
|
39
|
+
user: string;
|
|
40
|
+
temperature: number;
|
|
41
|
+
abortMs: number;
|
|
42
|
+
}): Promise<string> {
|
|
43
|
+
this.calls.push({ ...p });
|
|
44
|
+
const step = this.script[this.#i] ?? this.script[this.script.length - 1];
|
|
45
|
+
this.#i += 1;
|
|
46
|
+
if (step instanceof Error) {
|
|
47
|
+
throw step;
|
|
48
|
+
}
|
|
49
|
+
return step as string;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("buildCommitPrompt", () => {
|
|
54
|
+
it("encodes the Conventional Commits type list in the system prompt", () => {
|
|
55
|
+
const { system } = buildCommitPrompt(ctx);
|
|
56
|
+
for (const t of [
|
|
57
|
+
"feat",
|
|
58
|
+
"fix",
|
|
59
|
+
"docs",
|
|
60
|
+
"style",
|
|
61
|
+
"refactor",
|
|
62
|
+
"perf",
|
|
63
|
+
"test",
|
|
64
|
+
"build",
|
|
65
|
+
"ci",
|
|
66
|
+
"chore",
|
|
67
|
+
]) {
|
|
68
|
+
expect(system).toContain(t);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("instructs imperative mood in the system prompt", () => {
|
|
73
|
+
const { system } = buildCommitPrompt(ctx);
|
|
74
|
+
expect(system.toLowerCase()).toContain("imperative");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("instructs returning only the commit message (no fences/commentary)", () => {
|
|
78
|
+
const { system } = buildCommitPrompt(ctx);
|
|
79
|
+
const lower = system.toLowerCase();
|
|
80
|
+
expect(lower).toContain("only");
|
|
81
|
+
expect(lower).toContain("commit message");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("describes the type(scope): subject shape", () => {
|
|
85
|
+
const { system } = buildCommitPrompt(ctx);
|
|
86
|
+
expect(system).toContain("type(scope): subject");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders the branch into the user prompt", () => {
|
|
90
|
+
const { user } = buildCommitPrompt(ctx);
|
|
91
|
+
expect(user).toContain("feat/access-invites");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("renders the recent commits into the user prompt", () => {
|
|
95
|
+
const { user } = buildCommitPrompt(ctx);
|
|
96
|
+
expect(user).toContain("feat(web): manage access invites");
|
|
97
|
+
expect(user).toContain("chore(web): remove stale helper");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders per-file status, path, and diff into the user prompt", () => {
|
|
101
|
+
const { user } = buildCommitPrompt(ctx);
|
|
102
|
+
expect(user).toContain("M");
|
|
103
|
+
expect(user).toContain("apps/web/src/invites.ts");
|
|
104
|
+
expect(user).toContain("@@ -1 +1 @@");
|
|
105
|
+
expect(user).toContain("A");
|
|
106
|
+
expect(user).toContain("apps/web/src/invites.test.ts");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("omits a negative-constraint block when no previous message is given", () => {
|
|
110
|
+
const { user } = buildCommitPrompt(ctx);
|
|
111
|
+
expect(user.toLowerCase()).not.toContain("already generated and rejected");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("appends a negative-constraint block when previous is provided", () => {
|
|
115
|
+
const { user } = buildCommitPrompt(ctx, { previous: "feat: do the thing" });
|
|
116
|
+
expect(user.toLowerCase()).toContain("already generated and rejected");
|
|
117
|
+
expect(user).toContain("feat: do the thing");
|
|
118
|
+
expect(user.toLowerCase()).toContain("materially different");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("treats an explicit undefined previous as 'no previous'", () => {
|
|
122
|
+
const { user } = buildCommitPrompt(ctx, { previous: undefined });
|
|
123
|
+
expect(user.toLowerCase()).not.toContain("already generated and rejected");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("stripFormatting", () => {
|
|
128
|
+
it("returns plain text untouched (only trimmed)", () => {
|
|
129
|
+
expect(stripFormatting("feat: add login")).toBe("feat: add login");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("strips a plain triple-backtick fence", () => {
|
|
133
|
+
expect(stripFormatting("```\nfeat: add login\n```")).toBe("feat: add login");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("strips a fence with a language tag", () => {
|
|
137
|
+
expect(stripFormatting("```text\nfeat: add login\n```")).toBe("feat: add login");
|
|
138
|
+
expect(stripFormatting("```bash\nfix: patch parser\n```")).toBe("fix: patch parser");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("strips surrounding double quotes", () => {
|
|
142
|
+
expect(stripFormatting('"feat: add login"')).toBe("feat: add login");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("strips surrounding single quotes", () => {
|
|
146
|
+
expect(stripFormatting("'feat: add login'")).toBe("feat: add login");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("trims surrounding whitespace", () => {
|
|
150
|
+
expect(stripFormatting(" \n feat: add login \n ")).toBe("feat: add login");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("strips fence then trims inner content", () => {
|
|
154
|
+
expect(stripFormatting("```\n\n fix: trim me \n\n```")).toBe("fix: trim me");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("generateCommitMessage", () => {
|
|
159
|
+
it("returns the stripped message on a clean first response", async () => {
|
|
160
|
+
const model = new ScriptedModel(["```\nfeat(web): add invites\n```"]);
|
|
161
|
+
const msg = await generateCommitMessage({ ctx, model });
|
|
162
|
+
expect(msg).toBe("feat(web): add invites");
|
|
163
|
+
expect(model.calls).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("passes the default temperature and abort budget through to the model", async () => {
|
|
167
|
+
const model = new ScriptedModel(["feat: ok"]);
|
|
168
|
+
await generateCommitMessage({ ctx, model });
|
|
169
|
+
expect(model.calls[0]?.temperature).toBe(DEFAULT_TEMPERATURE);
|
|
170
|
+
expect(model.calls[0]?.abortMs).toBe(DEFAULT_ABORT_MS);
|
|
171
|
+
expect(DEFAULT_TEMPERATURE).toBe(0.3);
|
|
172
|
+
expect(DEFAULT_ABORT_MS).toBe(30000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("forwards the built prompt to the model", async () => {
|
|
176
|
+
const model = new ScriptedModel(["feat: ok"]);
|
|
177
|
+
await generateCommitMessage({ ctx, model });
|
|
178
|
+
expect(model.calls[0]?.user).toContain("feat/access-invites");
|
|
179
|
+
expect(model.calls[0]?.system).toContain("feat");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("retries after a rejection then succeeds", async () => {
|
|
183
|
+
const model = new ScriptedModel([new Error("model unavailable"), "fix: recovered"]);
|
|
184
|
+
const msg = await generateCommitMessage({ ctx, model });
|
|
185
|
+
expect(msg).toBe("fix: recovered");
|
|
186
|
+
expect(model.calls).toHaveLength(2);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("retries after empty/whitespace output then succeeds", async () => {
|
|
190
|
+
const model = new ScriptedModel([" \n ", "feat: now valid"]);
|
|
191
|
+
const msg = await generateCommitMessage({ ctx, model });
|
|
192
|
+
expect(msg).toBe("feat: now valid");
|
|
193
|
+
expect(model.calls).toHaveLength(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("treats a fence wrapping only whitespace as empty and retries", async () => {
|
|
197
|
+
const model = new ScriptedModel(["```\n \n```", "chore: real"]);
|
|
198
|
+
const msg = await generateCommitMessage({ ctx, model });
|
|
199
|
+
expect(msg).toBe("chore: real");
|
|
200
|
+
expect(model.calls).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("throws CommitMessageError after MAX_RETRIES exhausted (fail closed)", async () => {
|
|
204
|
+
const model = new ScriptedModel([new Error("down"), new Error("down"), new Error("down")]);
|
|
205
|
+
await expect(generateCommitMessage({ ctx, model })).rejects.toBeInstanceOf(CommitMessageError);
|
|
206
|
+
expect(model.calls).toHaveLength(MAX_RETRIES);
|
|
207
|
+
expect(MAX_RETRIES).toBe(3);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("never returns an empty message — throws when every attempt is blank", async () => {
|
|
211
|
+
const model = new ScriptedModel(["", " ", "```\n```"]);
|
|
212
|
+
await expect(generateCommitMessage({ ctx, model })).rejects.toBeInstanceOf(CommitMessageError);
|
|
213
|
+
expect(model.calls).toHaveLength(MAX_RETRIES);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("threads the negative constraint through when opts.previous is set", async () => {
|
|
217
|
+
const model = new ScriptedModel(["feat: a different one"]);
|
|
218
|
+
await generateCommitMessage({
|
|
219
|
+
ctx,
|
|
220
|
+
model,
|
|
221
|
+
opts: { previous: "feat: the rejected one" },
|
|
222
|
+
});
|
|
223
|
+
expect(model.calls[0]?.user.toLowerCase()).toContain("already generated and rejected");
|
|
224
|
+
expect(model.calls[0]?.user).toContain("feat: the rejected one");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("zod-rejects a context missing branch", async () => {
|
|
228
|
+
const model = new ScriptedModel(["feat: ok"]);
|
|
229
|
+
const bad = { recentCommits: [], files: [] } as unknown as GitContext;
|
|
230
|
+
await expect(generateCommitMessage({ ctx: bad, model })).rejects.toBeDefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("zod-rejects a context whose files have the wrong shape", async () => {
|
|
234
|
+
const model = new ScriptedModel(["feat: ok"]);
|
|
235
|
+
const bad = {
|
|
236
|
+
branch: "main",
|
|
237
|
+
recentCommits: [],
|
|
238
|
+
files: [{ status: "M" }],
|
|
239
|
+
} as unknown as GitContext;
|
|
240
|
+
await expect(generateCommitMessage({ ctx: bad, model })).rejects.toBeDefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("CommitMessageError is a typed Error subclass with a name", () => {
|
|
244
|
+
const err = new CommitMessageError("boom");
|
|
245
|
+
expect(err).toBeInstanceOf(Error);
|
|
246
|
+
expect(err.name).toBe("CommitMessageError");
|
|
247
|
+
expect(err.message).toBe("boom");
|
|
248
|
+
});
|
|
249
|
+
});
|