@nebutra/agent-runtime 0.2.0 → 0.2.2

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.
Files changed (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -1,184 +0,0 @@
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 DELETED
@@ -1,140 +0,0 @@
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
- }
@@ -1,249 +0,0 @@
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
- });