@vellumai/assistant 0.9.0-dev.202606170806.f7bee44 → 0.9.0-dev.202606171417.d2192ab
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/openapi.yaml +56 -9
- package/package.json +1 -1
- package/src/__tests__/scheduler-reuse-conversation.test.ts +8 -5
- package/src/cli/commands/plugins.ts +57 -5
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
- package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +134 -4
- package/src/cli/lib/__tests__/plugin-surfaces.test.ts +111 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +53 -11
- package/src/cli/lib/inspect-plugin.ts +12 -1
- package/src/cli/lib/merge-plugin-tree.ts +149 -49
- package/src/cli/lib/plugin-surfaces.ts +104 -0
- package/src/cli/lib/upgrade-plugin.ts +64 -36
- package/src/config/schemas/llm.ts +1 -0
- package/src/memory/db-init.ts +2 -0
- package/src/memory/migrations/292-schedule-default-no-reuse-conversation.test.ts +67 -0
- package/src/memory/migrations/292-schedule-default-no-reuse-conversation.ts +25 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/providers/atlascloud/client.ts +87 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/model-catalog.ts +29 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -13
- package/src/runtime/routes/plugins-routes.ts +40 -7
- package/src/runtime/routes/secret-routes.ts +10 -0
- package/src/schedule/schedule-store.ts +1 -1
package/openapi.yaml
CHANGED
|
@@ -13241,7 +13241,7 @@ paths:
|
|
|
13241
13241
|
type: string
|
|
13242
13242
|
description:
|
|
13243
13243
|
"Filter by provider. One of: anthropic, openai, gemini, ollama, fireworks, openrouter, openai-compatible,
|
|
13244
|
-
minimax"
|
|
13244
|
+
minimax, atlascloud"
|
|
13245
13245
|
responses:
|
|
13246
13246
|
"200":
|
|
13247
13247
|
description: Successful response
|
|
@@ -19573,6 +19573,35 @@ paths:
|
|
|
19573
19573
|
- type: string
|
|
19574
19574
|
- type: "null"
|
|
19575
19575
|
description: Marketplace fetch error message, when the catalog could not be read.
|
|
19576
|
+
surfaces:
|
|
19577
|
+
anyOf:
|
|
19578
|
+
- type: object
|
|
19579
|
+
properties:
|
|
19580
|
+
skills:
|
|
19581
|
+
type: array
|
|
19582
|
+
items:
|
|
19583
|
+
type: string
|
|
19584
|
+
description: Skill ids shipped at `skills/<id>/SKILL.md`.
|
|
19585
|
+
hooks:
|
|
19586
|
+
type: array
|
|
19587
|
+
items:
|
|
19588
|
+
type: string
|
|
19589
|
+
description: Lifecycle hook names from `hooks/<name>.{ts,js}` (e.g. `pre-model-call`).
|
|
19590
|
+
tools:
|
|
19591
|
+
type: array
|
|
19592
|
+
items:
|
|
19593
|
+
type: string
|
|
19594
|
+
description:
|
|
19595
|
+
Registered tool names from `tools/<name>.{ts,js}` (filenames derived to tool names, e.g. `create-issue` →
|
|
19596
|
+
`create_issue`).
|
|
19597
|
+
required:
|
|
19598
|
+
- skills
|
|
19599
|
+
- hooks
|
|
19600
|
+
- tools
|
|
19601
|
+
additionalProperties: false
|
|
19602
|
+
description: Surfaces the installed copy contributes, read from its on-disk tree.
|
|
19603
|
+
- type: "null"
|
|
19604
|
+
description: Surfaces the installed copy contributes (skills, hooks, tools); null when the plugin is not installed.
|
|
19576
19605
|
required:
|
|
19577
19606
|
- name
|
|
19578
19607
|
- installed
|
|
@@ -19580,6 +19609,7 @@ paths:
|
|
|
19580
19609
|
- local
|
|
19581
19610
|
- remote
|
|
19582
19611
|
- remoteError
|
|
19612
|
+
- surfaces
|
|
19583
19613
|
additionalProperties: false
|
|
19584
19614
|
"400":
|
|
19585
19615
|
description: The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).
|
|
@@ -19596,11 +19626,12 @@ paths:
|
|
|
19596
19626
|
commit already equals the pin; pass `dryRun` to preview the move (`outcome: "would-upgrade"`) without touching
|
|
19597
19627
|
the install. Installs lacking provenance are re-pinned to the current SHA. The assistant must be restarted to
|
|
19598
19628
|
load the upgraded code. `strategy` controls how local edits are reconciled: `overwrite` (default) discards them
|
|
19599
|
-
and re-installs the pin wholesale; `ours`/`theirs` perform a three-way merge against the
|
|
19600
|
-
commit, carrying non-conflicting edits from both sides forward and resolving conflicting
|
|
19601
|
-
edit or the pin
|
|
19602
|
-
|
|
19603
|
-
|
|
19629
|
+
and re-installs the pin wholesale; `ours`/`theirs`/`assistant` perform a three-way merge against the
|
|
19630
|
+
re-materialized install commit, carrying non-conflicting edits from both sides forward and resolving conflicting
|
|
19631
|
+
hunks toward the local edit (`ours`) or the pin (`theirs`), or writing git conflict markers into the file and
|
|
19632
|
+
reporting them in `conflicts`/`binaryConflicts` for the assistant to resolve (`assistant`). A merge strategy
|
|
19633
|
+
whose install-time baseline cannot be reconstructed returns 409. Mirrors the CLI''s `assistant plugins upgrade
|
|
19634
|
+
<name> [--strategy <s>]`.'
|
|
19604
19635
|
tags:
|
|
19605
19636
|
- plugins
|
|
19606
19637
|
parameters:
|
|
@@ -19689,6 +19720,20 @@ paths:
|
|
|
19689
19720
|
- overwrite
|
|
19690
19721
|
- assistant
|
|
19691
19722
|
description: Conflict-resolution strategy the upgrade applied.
|
|
19723
|
+
conflicts:
|
|
19724
|
+
type: array
|
|
19725
|
+
items:
|
|
19726
|
+
type: string
|
|
19727
|
+
description:
|
|
19728
|
+
"Paths left for the assistant to resolve under the `assistant` strategy: text files carry git conflict
|
|
19729
|
+
markers, modify/delete divergences keep the surviving content. Empty for other strategies."
|
|
19730
|
+
binaryConflicts:
|
|
19731
|
+
type: array
|
|
19732
|
+
items:
|
|
19733
|
+
type: string
|
|
19734
|
+
description:
|
|
19735
|
+
Binary files that conflicted under the `assistant` strategy; the local copy was kept since markers cannot
|
|
19736
|
+
be written into binary content. Empty for other strategies.
|
|
19692
19737
|
provenanceWasUnknown:
|
|
19693
19738
|
type: boolean
|
|
19694
19739
|
description:
|
|
@@ -19705,12 +19750,12 @@ paths:
|
|
|
19705
19750
|
- fileCount
|
|
19706
19751
|
- dryRun
|
|
19707
19752
|
- strategy
|
|
19753
|
+
- conflicts
|
|
19754
|
+
- binaryConflicts
|
|
19708
19755
|
- provenanceWasUnknown
|
|
19709
19756
|
additionalProperties: false
|
|
19710
19757
|
"400":
|
|
19711
|
-
description:
|
|
19712
|
-
The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters), or the
|
|
19713
|
-
`assistant` strategy was requested (not yet supported).
|
|
19758
|
+
description: The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).
|
|
19714
19759
|
"404":
|
|
19715
19760
|
description: No copy of the plugin is installed, or its source resolves to nothing.
|
|
19716
19761
|
"409":
|
|
@@ -28199,6 +28244,7 @@ components:
|
|
|
28199
28244
|
- openrouter
|
|
28200
28245
|
- openai-compatible
|
|
28201
28246
|
- minimax
|
|
28247
|
+
- atlascloud
|
|
28202
28248
|
ProfilePatchEntry:
|
|
28203
28249
|
type: object
|
|
28204
28250
|
properties:
|
|
@@ -28556,6 +28602,7 @@ components:
|
|
|
28556
28602
|
- openrouter
|
|
28557
28603
|
- openai-compatible
|
|
28558
28604
|
- minimax
|
|
28605
|
+
- atlascloud
|
|
28559
28606
|
Auth:
|
|
28560
28607
|
oneOf:
|
|
28561
28608
|
- type: object
|
package/package.json
CHANGED
|
@@ -210,10 +210,13 @@ describe("scheduler conversation reuse", () => {
|
|
|
210
210
|
expect(runs2[0].conversationId).toBe(firstConversationId);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
test("recurring schedule defaults to reuseConversation=
|
|
213
|
+
test("recurring schedule defaults to reuseConversation=false", async () => {
|
|
214
214
|
/**
|
|
215
215
|
* When no explicit reuseConversation is provided, recurring schedules
|
|
216
|
-
* default to
|
|
216
|
+
* default to false — each run gets a fresh conversation. This keeps a
|
|
217
|
+
* weak model's per-run context bounded instead of accumulating a long,
|
|
218
|
+
* self-similar transcript that primes it to repeat or drift; durable
|
|
219
|
+
* cross-run state belongs in workspace files and memory, not history.
|
|
217
220
|
*/
|
|
218
221
|
|
|
219
222
|
// GIVEN a recurring schedule with no explicit reuseConversation
|
|
@@ -224,7 +227,7 @@ describe("scheduler conversation reuse", () => {
|
|
|
224
227
|
message: "Default reuse message",
|
|
225
228
|
syntax: "rrule",
|
|
226
229
|
expression: rruleExpr,
|
|
227
|
-
// no explicit reuseConversation — should default to
|
|
230
|
+
// no explicit reuseConversation — should default to false
|
|
228
231
|
});
|
|
229
232
|
|
|
230
233
|
// WHEN the schedule fires for the first time
|
|
@@ -250,9 +253,9 @@ describe("scheduler conversation reuse", () => {
|
|
|
250
253
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
251
254
|
scheduler2.stop();
|
|
252
255
|
|
|
253
|
-
// THEN
|
|
256
|
+
// THEN a fresh conversation is created instead of reusing the first
|
|
254
257
|
expect(processedMessages).toHaveLength(1);
|
|
255
|
-
expect(processedMessages[0].conversationId).toBe(firstConversationId);
|
|
258
|
+
expect(processedMessages[0].conversationId).not.toBe(firstConversationId);
|
|
256
259
|
});
|
|
257
260
|
|
|
258
261
|
test("recurring schedule with reuseConversation=false creates new conversation each run", async () => {
|
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
PluginNotUpgradableError,
|
|
47
47
|
type PluginUpgradeResult,
|
|
48
48
|
type PluginUpgradeStrategy,
|
|
49
|
-
PluginUpgradeStrategyUnsupportedError,
|
|
50
49
|
upgradePlugin,
|
|
51
50
|
} from "../lib/upgrade-plugin.js";
|
|
52
51
|
import { getCliLogger } from "../logger.js";
|
|
@@ -76,6 +75,7 @@ Examples:
|
|
|
76
75
|
$ assistant plugins upgrade example --dry-run
|
|
77
76
|
$ assistant plugins upgrade example --strategy ours
|
|
78
77
|
$ assistant plugins upgrade example --strategy theirs
|
|
78
|
+
$ assistant plugins upgrade example --strategy assistant
|
|
79
79
|
$ assistant plugins search example
|
|
80
80
|
$ assistant plugins search "^example"
|
|
81
81
|
$ assistant plugins search example --json
|
|
@@ -180,7 +180,7 @@ Examples:
|
|
|
180
180
|
plugins
|
|
181
181
|
.command("inspect <name>")
|
|
182
182
|
.description(
|
|
183
|
-
"Show a plugin's local install metadata
|
|
183
|
+
"Show a plugin's local install metadata, the marketplace pin, whether an update is available, and the surfaces (skills, hooks, tools) it contributes",
|
|
184
184
|
)
|
|
185
185
|
.option("--json", "Emit machine-readable JSON instead of a summary")
|
|
186
186
|
.action(async (name: string, opts: { json?: boolean }) => {
|
|
@@ -465,7 +465,6 @@ Examples:
|
|
|
465
465
|
if (
|
|
466
466
|
err instanceof PluginNotInstalledError ||
|
|
467
467
|
err instanceof PluginNotUpgradableError ||
|
|
468
|
-
err instanceof PluginUpgradeStrategyUnsupportedError ||
|
|
469
468
|
err instanceof PluginMergeBaselineError ||
|
|
470
469
|
err instanceof InvalidPluginNameError
|
|
471
470
|
) {
|
|
@@ -550,12 +549,20 @@ function remoteLocation(remote: PluginRemoteInfo): string {
|
|
|
550
549
|
* `location`, with a `drift` line under the installed copy.
|
|
551
550
|
*/
|
|
552
551
|
function formatInspection(inspection: PluginInspection): string[] {
|
|
553
|
-
const { name, status, local, remote, remoteError } = inspection;
|
|
552
|
+
const { name, status, local, remote, remoteError, surfaces } = inspection;
|
|
554
553
|
const lines: string[] = [name, "─".repeat(44)];
|
|
555
554
|
const topRow = (label: string, value: string) =>
|
|
556
555
|
lines.push(`${label.padEnd(11)} ${value}`);
|
|
557
556
|
const blockRow = (label: string, value: string) =>
|
|
558
557
|
lines.push(` ${label.padEnd(9)} ${value}`);
|
|
558
|
+
// A surface block: the surface type as a heading, then its items indented
|
|
559
|
+
// under it. Omitted entirely when the plugin contributes none of that type,
|
|
560
|
+
// so the listing only ever shows what the plugin actually contributes.
|
|
561
|
+
const surfaceBlock = (label: string, items: readonly string[]) => {
|
|
562
|
+
if (items.length === 0) return;
|
|
563
|
+
lines.push(label);
|
|
564
|
+
for (const item of items) lines.push(` ${item}`);
|
|
565
|
+
};
|
|
559
566
|
|
|
560
567
|
topRow("status", statusLine(status));
|
|
561
568
|
|
|
@@ -590,6 +597,12 @@ function formatInspection(inspection: PluginInspection): string[] {
|
|
|
590
597
|
const description = remote?.description ?? local?.description ?? null;
|
|
591
598
|
if (description) topRow("description", description);
|
|
592
599
|
|
|
600
|
+
if (surfaces) {
|
|
601
|
+
surfaceBlock("skills", surfaces.skills);
|
|
602
|
+
surfaceBlock("hooks", surfaces.hooks);
|
|
603
|
+
surfaceBlock("tools", surfaces.tools);
|
|
604
|
+
}
|
|
605
|
+
|
|
593
606
|
for (const issue of local?.issues ?? []) topRow("issue", issue);
|
|
594
607
|
|
|
595
608
|
return lines;
|
|
@@ -628,10 +641,49 @@ function formatUpgrade(result: PluginUpgradeResult): string[] {
|
|
|
628
641
|
result.fileCount === null
|
|
629
642
|
? ""
|
|
630
643
|
: `(${result.fileCount} file${result.fileCount === 1 ? "" : "s"}) `;
|
|
644
|
+
const hasConflicts =
|
|
645
|
+
result.conflicts.length > 0 || result.binaryConflicts.length > 0;
|
|
646
|
+
|
|
647
|
+
// Under `assistant`, an unresolved merge leaves conflicts in the tree —
|
|
648
|
+
// the plugin would fail to load while any remain, so the usual "restart
|
|
649
|
+
// now" guidance is replaced with resolution instructions. Most conflicts
|
|
650
|
+
// carry git markers, but a modify/delete divergence (a file edited on one
|
|
651
|
+
// side and deleted on the other) keeps the surviving content with no
|
|
652
|
+
// markers, so the guidance covers both rather than assuming markers.
|
|
653
|
+
if (result.strategy === "assistant" && hasConflicts) {
|
|
654
|
+
const lines = [
|
|
655
|
+
`Merged "${name}" ${move} with conflicts`,
|
|
656
|
+
"",
|
|
657
|
+
`${count}→ ${result.target}`,
|
|
658
|
+
];
|
|
659
|
+
if (result.conflicts.length > 0) {
|
|
660
|
+
lines.push(
|
|
661
|
+
"",
|
|
662
|
+
`Resolve ${result.conflicts.length} conflicted file${result.conflicts.length === 1 ? "" : "s"} (open each: resolve its git conflict markers, or — if a modify/delete divergence kept the file with none — decide whether to keep or remove it):`,
|
|
663
|
+
...result.conflicts.map((p) => ` ${p}`),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (result.binaryConflicts.length > 0) {
|
|
667
|
+
lines.push(
|
|
668
|
+
"",
|
|
669
|
+
`Binary conflict${result.binaryConflicts.length === 1 ? "" : "s"} (kept the local copy — choose a version manually):`,
|
|
670
|
+
...result.binaryConflicts.map((p) => ` ${p}`),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
lines.push(
|
|
674
|
+
"",
|
|
675
|
+
"Resolve the conflicts, then restart the assistant to pick up the upgrade.",
|
|
676
|
+
);
|
|
677
|
+
if (provenanceNote) lines.push(provenanceNote);
|
|
678
|
+
return lines;
|
|
679
|
+
}
|
|
680
|
+
|
|
631
681
|
const mergeNote =
|
|
632
682
|
result.strategy === "ours" || result.strategy === "theirs"
|
|
633
683
|
? `Local edits were merged (--strategy ${result.strategy}).`
|
|
634
|
-
:
|
|
684
|
+
: result.strategy === "assistant"
|
|
685
|
+
? "Local edits were merged with no conflicts (--strategy assistant)."
|
|
686
|
+
: null;
|
|
635
687
|
const lines = [
|
|
636
688
|
`Upgraded "${name}" ${move}`,
|
|
637
689
|
"",
|
|
@@ -90,6 +90,8 @@ function installPlugin(
|
|
|
90
90
|
} | null;
|
|
91
91
|
/** Embed a content fingerprint of the materialized tree in the sidecar. */
|
|
92
92
|
fingerprint?: boolean;
|
|
93
|
+
/** Materialize surface directories (`hooks/<name>.ts`, `skills/<id>/SKILL.md`). */
|
|
94
|
+
surfaces?: { hooks?: string[]; tools?: string[]; skills?: string[] };
|
|
93
95
|
} = {},
|
|
94
96
|
): void {
|
|
95
97
|
const dir = join(workspace, name);
|
|
@@ -102,6 +104,18 @@ function installPlugin(
|
|
|
102
104
|
description: "Installed copy.",
|
|
103
105
|
}),
|
|
104
106
|
);
|
|
107
|
+
for (const hook of opts.surfaces?.hooks ?? []) {
|
|
108
|
+
mkdirSync(join(dir, "hooks"), { recursive: true });
|
|
109
|
+
writeFileSync(join(dir, "hooks", `${hook}.ts`), "export default () => {};");
|
|
110
|
+
}
|
|
111
|
+
for (const tool of opts.surfaces?.tools ?? []) {
|
|
112
|
+
mkdirSync(join(dir, "tools"), { recursive: true });
|
|
113
|
+
writeFileSync(join(dir, "tools", `${tool}.ts`), "export default {};");
|
|
114
|
+
}
|
|
115
|
+
for (const skill of opts.surfaces?.skills ?? []) {
|
|
116
|
+
mkdirSync(join(dir, "skills", skill), { recursive: true });
|
|
117
|
+
writeFileSync(join(dir, "skills", skill, "SKILL.md"), `# ${skill}`);
|
|
118
|
+
}
|
|
105
119
|
if (opts.sidecar !== null) {
|
|
106
120
|
const sidecar = opts.sidecar ?? { commit: SHA_A };
|
|
107
121
|
// Mirror install: fingerprint the tree before the sidecar is written so it
|
|
@@ -367,6 +381,46 @@ describe("inspectPlugin", () => {
|
|
|
367
381
|
expect(result.local?.localChanges).toBeNull();
|
|
368
382
|
});
|
|
369
383
|
|
|
384
|
+
test("reports the surfaces an installed copy contributes", async () => {
|
|
385
|
+
// GIVEN an installed plugin shipping hooks and skills but no tools
|
|
386
|
+
installPlugin(workspace, "level-up", {
|
|
387
|
+
sidecar: { commit: SHA_A },
|
|
388
|
+
surfaces: {
|
|
389
|
+
hooks: ["post-model-call", "init"],
|
|
390
|
+
skills: ["second-skill", "first-skill"],
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
|
|
394
|
+
|
|
395
|
+
// WHEN it is inspected
|
|
396
|
+
const result = await inspectPlugin(
|
|
397
|
+
{ name: "level-up" },
|
|
398
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// THEN the contributed surfaces are listed (sorted) and tools is empty
|
|
402
|
+
expect(result.surfaces).toEqual({
|
|
403
|
+
skills: ["first-skill", "second-skill"],
|
|
404
|
+
hooks: ["init", "post-model-call"],
|
|
405
|
+
tools: [],
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("leaves surfaces null when the plugin is not installed", async () => {
|
|
410
|
+
// GIVEN a marketplace entry with no local copy to walk
|
|
411
|
+
const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_B) });
|
|
412
|
+
|
|
413
|
+
// WHEN it is inspected
|
|
414
|
+
const result = await inspectPlugin(
|
|
415
|
+
{ name: "level-up" },
|
|
416
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// THEN there is no tree to read surfaces from
|
|
420
|
+
expect(result.installed).toBe(false);
|
|
421
|
+
expect(result.surfaces).toBeNull();
|
|
422
|
+
});
|
|
423
|
+
|
|
370
424
|
test("throws when the plugin is neither installed nor in the marketplace", async () => {
|
|
371
425
|
// GIVEN no local copy and an empty catalog
|
|
372
426
|
const fetch = makeFetch({ marketplace: undefined });
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* The merge is exercised against three real temp directories (base/ours/theirs)
|
|
5
5
|
* and the merged tree is read back from a fourth (dest). `git merge-file` runs
|
|
6
6
|
* for real — these tests assert the line-level merge behavior (non-conflicting
|
|
7
|
-
* hunks from both sides survive; conflicts resolve toward
|
|
8
|
-
* file-level add/delete
|
|
7
|
+
* hunks from both sides survive; conflicts resolve toward `ours`/`theirs` or
|
|
8
|
+
* get conflict markers under `assistant`), the file-level add/delete
|
|
9
|
+
* resolution, and binary whole-file handling.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -97,7 +98,9 @@ describe("mergePluginTree", () => {
|
|
|
97
98
|
expect(read("local-only.txt")).toBe("local\n");
|
|
98
99
|
expect(read("remote-only.txt")).toBe("remote\n");
|
|
99
100
|
expect(read("conflict.txt")).toBe("ours\n");
|
|
100
|
-
expect(count).toBe(4);
|
|
101
|
+
expect(count.fileCount).toBe(4);
|
|
102
|
+
expect(count.conflicts).toEqual([]);
|
|
103
|
+
expect(count.binaryConflicts).toEqual([]);
|
|
101
104
|
});
|
|
102
105
|
|
|
103
106
|
test("resolves conflicts toward the pin under the theirs strategy", async () => {
|
|
@@ -289,7 +292,7 @@ describe("mergePluginTree", () => {
|
|
|
289
292
|
|
|
290
293
|
// THEN the sidecar is never carried through (the caller rewrites it)
|
|
291
294
|
expect(read("install-meta.json")).toBeNull();
|
|
292
|
-
expect(count).toBe(1);
|
|
295
|
+
expect(count.fileCount).toBe(1);
|
|
293
296
|
});
|
|
294
297
|
|
|
295
298
|
test("merges files in nested directories", async () => {
|
|
@@ -310,4 +313,131 @@ describe("mergePluginTree", () => {
|
|
|
310
313
|
// THEN the nested path is preserved and resolved
|
|
311
314
|
expect(read("src/a/b.txt")).toBe("ours\n");
|
|
312
315
|
});
|
|
316
|
+
|
|
317
|
+
test("writes git conflict markers and reports the path under the assistant strategy", async () => {
|
|
318
|
+
// GIVEN a file edited on disjoint lines by each side plus one true conflict
|
|
319
|
+
writeTree(baseDir, { "common.txt": "a\nb\nc\n", "conflict.txt": "base\n" });
|
|
320
|
+
writeTree(oursDir, { "common.txt": "A\nb\nc\n", "conflict.txt": "ours\n" });
|
|
321
|
+
writeTree(theirsDir, {
|
|
322
|
+
"common.txt": "a\nb\nC\n",
|
|
323
|
+
"conflict.txt": "theirs\n",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// WHEN merged with the `assistant` strategy
|
|
327
|
+
const result = await mergePluginTree({
|
|
328
|
+
baseDir,
|
|
329
|
+
oursDir,
|
|
330
|
+
theirsDir,
|
|
331
|
+
destDir,
|
|
332
|
+
strategy: "assistant",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// THEN the non-conflicting edits still auto-merge and only the true
|
|
336
|
+
// conflict carries markers naming both sides, and its path is reported
|
|
337
|
+
expect(read("common.txt")).toBe("A\nb\nC\n");
|
|
338
|
+
const conflict = read("conflict.txt") ?? "";
|
|
339
|
+
expect(conflict).toContain("<<<<<<<");
|
|
340
|
+
expect(conflict).toContain("=======");
|
|
341
|
+
expect(conflict).toContain(">>>>>>>");
|
|
342
|
+
expect(conflict).toContain("ours\n");
|
|
343
|
+
expect(conflict).toContain("theirs\n");
|
|
344
|
+
expect(result.conflicts).toEqual(["conflict.txt"]);
|
|
345
|
+
expect(result.binaryConflicts).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("uses the supplied conflict labels in the markers", async () => {
|
|
349
|
+
// GIVEN a true conflict and custom marker labels
|
|
350
|
+
writeTree(baseDir, { "f.txt": "base\n" });
|
|
351
|
+
writeTree(oursDir, { "f.txt": "ours\n" });
|
|
352
|
+
writeTree(theirsDir, { "f.txt": "theirs\n" });
|
|
353
|
+
|
|
354
|
+
// WHEN merged with the `assistant` strategy and explicit labels
|
|
355
|
+
await mergePluginTree({
|
|
356
|
+
baseDir,
|
|
357
|
+
oursDir,
|
|
358
|
+
theirsDir,
|
|
359
|
+
destDir,
|
|
360
|
+
strategy: "assistant",
|
|
361
|
+
conflictLabels: {
|
|
362
|
+
ours: "local edits (was abc1234)",
|
|
363
|
+
base: "install baseline abc1234",
|
|
364
|
+
theirs: "upgrade pin def5678",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// THEN the marker lines carry those labels
|
|
369
|
+
const merged = read("f.txt") ?? "";
|
|
370
|
+
expect(merged).toContain("<<<<<<< local edits (was abc1234)");
|
|
371
|
+
expect(merged).toContain(">>>>>>> upgrade pin def5678");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("auto-merges with no markers when the assistant strategy hits no true conflict", async () => {
|
|
375
|
+
// GIVEN edits on disjoint lines of the same file (no overlapping hunk)
|
|
376
|
+
writeTree(baseDir, { "f.txt": "a\nb\nc\n" });
|
|
377
|
+
writeTree(oursDir, { "f.txt": "A\nb\nc\n" });
|
|
378
|
+
writeTree(theirsDir, { "f.txt": "a\nb\nC\n" });
|
|
379
|
+
|
|
380
|
+
// WHEN merged with the `assistant` strategy
|
|
381
|
+
const result = await mergePluginTree({
|
|
382
|
+
baseDir,
|
|
383
|
+
oursDir,
|
|
384
|
+
theirsDir,
|
|
385
|
+
destDir,
|
|
386
|
+
strategy: "assistant",
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// THEN both edits merge cleanly with no markers and nothing is reported
|
|
390
|
+
const merged = read("f.txt") ?? "";
|
|
391
|
+
expect(merged).toBe("A\nb\nC\n");
|
|
392
|
+
expect(merged).not.toContain("<<<<<<<");
|
|
393
|
+
expect(result.conflicts).toEqual([]);
|
|
394
|
+
expect(result.binaryConflicts).toEqual([]);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("keeps the local copy and reports a binary conflict under the assistant strategy", async () => {
|
|
398
|
+
// GIVEN a binary file (NUL bytes) diverged on both sides
|
|
399
|
+
const base = Buffer.from([0x00, 0x01, 0x02]);
|
|
400
|
+
const ours = Buffer.from([0x00, 0xaa, 0xbb]);
|
|
401
|
+
const theirs = Buffer.from([0x00, 0xcc, 0xdd]);
|
|
402
|
+
writeTree(baseDir, { "img.bin": base });
|
|
403
|
+
writeTree(oursDir, { "img.bin": ours });
|
|
404
|
+
writeTree(theirsDir, { "img.bin": theirs });
|
|
405
|
+
|
|
406
|
+
// WHEN merged with the `assistant` strategy
|
|
407
|
+
const result = await mergePluginTree({
|
|
408
|
+
baseDir,
|
|
409
|
+
oursDir,
|
|
410
|
+
theirsDir,
|
|
411
|
+
destDir,
|
|
412
|
+
strategy: "assistant",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// THEN the local copy is kept byte-for-byte (markers are impossible) and
|
|
416
|
+
// the path is surfaced as a binary conflict, not a marker conflict
|
|
417
|
+
expect(readFileSync(join(destDir, "img.bin")).equals(ours)).toBe(true);
|
|
418
|
+
expect(result.binaryConflicts).toEqual(["img.bin"]);
|
|
419
|
+
expect(result.conflicts).toEqual([]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("keeps the surviving content and reports a modify/delete conflict under the assistant strategy", async () => {
|
|
423
|
+
// GIVEN a file edited locally but deleted at the pin
|
|
424
|
+
writeTree(baseDir, { "f.txt": "base\n" });
|
|
425
|
+
writeTree(oursDir, { "f.txt": "local edit\n" });
|
|
426
|
+
writeTree(theirsDir, {});
|
|
427
|
+
|
|
428
|
+
// WHEN merged with the `assistant` strategy
|
|
429
|
+
const result = await mergePluginTree({
|
|
430
|
+
baseDir,
|
|
431
|
+
oursDir,
|
|
432
|
+
theirsDir,
|
|
433
|
+
destDir,
|
|
434
|
+
strategy: "assistant",
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// THEN the local edit is preserved (never silently dropped) and the
|
|
438
|
+
// modify/delete divergence is reported for resolution
|
|
439
|
+
expect(read("f.txt")).toBe("local edit\n");
|
|
440
|
+
expect(result.conflicts).toEqual(["f.txt"]);
|
|
441
|
+
expect(result.binaryConflicts).toEqual([]);
|
|
442
|
+
});
|
|
313
443
|
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for {@link detectPluginSurfaces}.
|
|
3
|
+
*
|
|
4
|
+
* Surface detection is a pure walk of an installed plugin's on-disk tree, so
|
|
5
|
+
* the fixtures materialize the `hooks/`, `tools/`, and `skills/` directory
|
|
6
|
+
* conventions in a real temp dir and assert the derived listing matches what
|
|
7
|
+
* the runtime loader / skills catalog would discover.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { detectPluginSurfaces } from "../plugin-surfaces.js";
|
|
16
|
+
|
|
17
|
+
let pluginDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
pluginDir = mkdtempSync(join(tmpdir(), "plugin-surfaces-"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
rmSync(pluginDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Create `<pluginDir>/<rel>` with empty contents, making parents as needed. */
|
|
28
|
+
function touch(rel: string): void {
|
|
29
|
+
const path = join(pluginDir, rel);
|
|
30
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
31
|
+
writeFileSync(path, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("detectPluginSurfaces", () => {
|
|
35
|
+
test("lists hooks, tools, and skills from the directory conventions", () => {
|
|
36
|
+
// GIVEN a plugin shipping two hooks, one tool, and two skills
|
|
37
|
+
touch("hooks/post-model-call.ts");
|
|
38
|
+
touch("hooks/init.ts");
|
|
39
|
+
touch("tools/summarize.ts");
|
|
40
|
+
touch("skills/first-skill/SKILL.md");
|
|
41
|
+
touch("skills/second-skill/SKILL.md");
|
|
42
|
+
|
|
43
|
+
// WHEN its surfaces are detected
|
|
44
|
+
const surfaces = detectPluginSurfaces(pluginDir);
|
|
45
|
+
|
|
46
|
+
// THEN each surface type lists its items, sorted
|
|
47
|
+
expect(surfaces.hooks).toEqual(["init", "post-model-call"]);
|
|
48
|
+
expect(surfaces.tools).toEqual(["summarize"]);
|
|
49
|
+
expect(surfaces.skills).toEqual(["first-skill", "second-skill"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reports tools under their registered name, not the raw filename", () => {
|
|
53
|
+
// GIVEN a tool whose filename is not already a valid tool-name segment
|
|
54
|
+
touch("tools/create-issue.ts");
|
|
55
|
+
|
|
56
|
+
// WHEN its surfaces are detected
|
|
57
|
+
const surfaces = detectPluginSurfaces(pluginDir);
|
|
58
|
+
|
|
59
|
+
// THEN the derived (registered) name is reported, matching the loader
|
|
60
|
+
expect(surfaces.tools).toEqual(["create_issue"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("omits surface types the plugin does not contribute", () => {
|
|
64
|
+
// GIVEN a plugin that ships only a hook
|
|
65
|
+
touch("hooks/post-model-call.ts");
|
|
66
|
+
|
|
67
|
+
// WHEN its surfaces are detected
|
|
68
|
+
const surfaces = detectPluginSurfaces(pluginDir);
|
|
69
|
+
|
|
70
|
+
// THEN the contributed type is listed and the others are empty
|
|
71
|
+
expect(surfaces.hooks).toEqual(["post-model-call"]);
|
|
72
|
+
expect(surfaces.tools).toEqual([]);
|
|
73
|
+
expect(surfaces.skills).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("prefers .js over .ts for the same basename and skips .d.ts declarations", () => {
|
|
77
|
+
// GIVEN a compiled hook shipping both .ts and .js plus a .d.ts declaration
|
|
78
|
+
touch("hooks/post-model-call.ts");
|
|
79
|
+
touch("hooks/post-model-call.js");
|
|
80
|
+
touch("hooks/post-model-call.d.ts");
|
|
81
|
+
|
|
82
|
+
// WHEN its surfaces are detected
|
|
83
|
+
const surfaces = detectPluginSurfaces(pluginDir);
|
|
84
|
+
|
|
85
|
+
// THEN the basename appears once and the declaration file is ignored
|
|
86
|
+
expect(surfaces.hooks).toEqual(["post-model-call"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("ignores skill directories without a SKILL.md", () => {
|
|
90
|
+
// GIVEN a skills dir with one real skill and one stray subdirectory
|
|
91
|
+
touch("skills/real-skill/SKILL.md");
|
|
92
|
+
touch("skills/not-a-skill/README.md");
|
|
93
|
+
|
|
94
|
+
// WHEN its surfaces are detected
|
|
95
|
+
const surfaces = detectPluginSurfaces(pluginDir);
|
|
96
|
+
|
|
97
|
+
// THEN only the directory carrying a SKILL.md is reported
|
|
98
|
+
expect(surfaces.skills).toEqual(["real-skill"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns empty surfaces for a plugin with no surface directories", () => {
|
|
102
|
+
// GIVEN a plugin tree with only a package.json
|
|
103
|
+
touch("package.json");
|
|
104
|
+
|
|
105
|
+
// WHEN its surfaces are detected
|
|
106
|
+
const surfaces = detectPluginSurfaces(pluginDir);
|
|
107
|
+
|
|
108
|
+
// THEN every surface type is empty
|
|
109
|
+
expect(surfaces).toEqual({ skills: [], hooks: [], tools: [] });
|
|
110
|
+
});
|
|
111
|
+
});
|