mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.
- New terminal import status `conflict` carrying { existing_skill_id,
existing_created_by, can_overwrite }; can_overwrite = requester is the
skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
existence (workspace-scoped) and creator permission, then replaces
description/content/config and fully replaces files (pruning files absent
from the new bundle). Preserves id, created_by, created_at, name, and
agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
create-by-name); any mid-write error rolls back the tx, leaving the original
skill untouched. Retrying a terminal request is a no-op.
Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.
Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).
Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.
Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.
Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.
Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add skill import conflict strategies
Co-authored-by: multica-agent <github@multica.ai>
* fix(i18n): sync skill import locale keys
Co-authored-by: multica-agent <github@multica.ai>
* docs: explain skill import conflict handling
Co-authored-by: multica-agent <github@multica.ai>
* docs: refresh skill import source map anchors
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
435 lines
16 KiB
Go
435 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// createImportTargetSkill inserts a skill (owned by ownerID) plus the given
|
|
// path->content files directly into the DB, returning its id. Used as the
|
|
// pre-existing skill that conflict / overwrite imports collide with.
|
|
func createImportTargetSkill(t *testing.T, name, ownerID string, files map[string]string) string {
|
|
t.Helper()
|
|
|
|
var skillID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
|
VALUES ($1, $2, 'original description', '# original', '{}'::jsonb, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, name, ownerID).Scan(&skillID); err != nil {
|
|
t.Fatalf("create target skill: %v", err)
|
|
}
|
|
for path, content := range files {
|
|
if _, err := testPool.Exec(context.Background(), `
|
|
INSERT INTO skill_file (skill_id, path, content) VALUES ($1, $2, $3)
|
|
`, skillID, path, content); err != nil {
|
|
t.Fatalf("create skill file: %v", err)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, skillID)
|
|
})
|
|
return skillID
|
|
}
|
|
|
|
// bindAgentToSkill creates a workspace agent and binds it to skillID via
|
|
// agent_skill, returning the agent id. Lets overwrite tests assert the binding
|
|
// survives the re-import.
|
|
func bindAgentToSkill(t *testing.T, skillID string) string {
|
|
t.Helper()
|
|
|
|
agentName := fmt.Sprintf("overwrite-test-agent-%d", time.Now().UnixNano())
|
|
var agentID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentName, testRuntimeID, testUserID).Scan(&agentID); err != nil {
|
|
t.Fatalf("create agent: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(context.Background(), `
|
|
INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)
|
|
`, agentID, skillID); err != nil {
|
|
t.Fatalf("bind agent skill: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
|
|
})
|
|
return agentID
|
|
}
|
|
|
|
func countAgentSkillBindings(t *testing.T, skillID string) int {
|
|
t.Helper()
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT count(*) FROM agent_skill WHERE skill_id = $1
|
|
`, skillID).Scan(&count); err != nil {
|
|
t.Fatalf("count agent_skill: %v", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func getSkillRow(t *testing.T, skillID string) (name, description, content, createdBy string) {
|
|
t.Helper()
|
|
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT name, description, content, COALESCE(created_by::text, '')
|
|
FROM skill WHERE id = $1
|
|
`, skillID).Scan(&name, &description, &content, &createdBy); err != nil {
|
|
t.Fatalf("get skill row: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// reportBundleBody builds the daemon "completed" report body for an import.
|
|
func reportBundleBody(name, description, content string, files map[string]string) map[string]any {
|
|
fileList := make([]map[string]any, 0, len(files))
|
|
for p, c := range files {
|
|
fileList = append(fileList, map[string]any{"path": p, "content": c})
|
|
}
|
|
return map[string]any{
|
|
"status": "completed",
|
|
"skill": map[string]any{
|
|
"name": name,
|
|
"description": description,
|
|
"content": content,
|
|
"source_path": "~/.claude/skills/review-helper",
|
|
"provider": "claude",
|
|
"files": fileList,
|
|
},
|
|
}
|
|
}
|
|
|
|
func initiateLocalSkillImport(t *testing.T, runtimeID string, body map[string]any) string {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(testUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills/import", body),
|
|
"runtimeId", runtimeID,
|
|
)
|
|
testHandler.InitiateImportLocalSkill(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("InitiateImportLocalSkill: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var importReq RuntimeLocalSkillImportRequest
|
|
if err := json.NewDecoder(w.Body).Decode(&importReq); err != nil {
|
|
t.Fatalf("decode import request: %v", err)
|
|
}
|
|
return importReq.ID
|
|
}
|
|
|
|
func reportLocalSkillImport(t *testing.T, runtimeID, requestID string, body map[string]any) {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newDaemonTokenRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/local-skills/import/"+requestID+"/result", body, testWorkspaceID, "overwrite-test-daemon"),
|
|
"runtimeId", runtimeID,
|
|
"requestId", requestID,
|
|
)
|
|
testHandler.ReportLocalSkillImportResult(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ReportLocalSkillImportResult: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func pollLocalSkillImport(t *testing.T, runtimeID, requestID string) RuntimeLocalSkillImportRequest {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(testUserID, http.MethodGet, "/api/runtimes/"+runtimeID+"/local-skills/import/"+requestID, nil),
|
|
"runtimeId", runtimeID,
|
|
"requestId", requestID,
|
|
)
|
|
testHandler.GetLocalSkillImportRequest(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetLocalSkillImportRequest: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var got RuntimeLocalSkillImportRequest
|
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode poll response: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// runLocalSkillImport drives initiate -> report -> poll and returns the
|
|
// terminal request.
|
|
func runLocalSkillImport(t *testing.T, runtimeID string, initBody, reportBody map[string]any) RuntimeLocalSkillImportRequest {
|
|
t.Helper()
|
|
requestID := initiateLocalSkillImport(t, runtimeID, initBody)
|
|
reportLocalSkillImport(t, runtimeID, requestID, reportBody)
|
|
return pollLocalSkillImport(t, runtimeID, requestID)
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImport_ConflictCreatorCanOverwrite(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
name := fmt.Sprintf("conflict-creator-%d", time.Now().UnixNano())
|
|
existingID := createImportTargetSkill(t, name, testUserID, nil)
|
|
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
map[string]any{"skill_key": "review-helper", "supports_conflict": true},
|
|
reportBundleBody(name, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillConflict {
|
|
t.Fatalf("status = %s, want conflict", got.Status)
|
|
}
|
|
if got.Conflict == nil {
|
|
t.Fatal("expected conflict metadata")
|
|
}
|
|
if got.Conflict.ExistingSkillID != existingID {
|
|
t.Fatalf("existing_skill_id = %q, want %q", got.Conflict.ExistingSkillID, existingID)
|
|
}
|
|
if got.Conflict.ExistingCreatedBy != testUserID {
|
|
t.Fatalf("existing_created_by = %q, want %q", got.Conflict.ExistingCreatedBy, testUserID)
|
|
}
|
|
if !got.Conflict.CanOverwrite {
|
|
t.Fatal("creator should be allowed to overwrite")
|
|
}
|
|
// A conflict must neither create a second skill nor mutate the original.
|
|
if n := countSkillsByName(t, name); n != 1 {
|
|
t.Fatalf("expected exactly 1 skill named %q, got %d", name, n)
|
|
}
|
|
if _, desc, _, _ := getSkillRow(t, existingID); desc != "original description" {
|
|
t.Fatalf("conflict must not modify the existing skill, description = %q", desc)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImport_ConflictNonCreatorCannotOverwrite(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
otherUserID := createRuntimeLocalSkillTestMember(t, "member")
|
|
name := fmt.Sprintf("conflict-noncreator-%d", time.Now().UnixNano())
|
|
existingID := createImportTargetSkill(t, name, otherUserID, nil)
|
|
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
map[string]any{"skill_key": "review-helper", "supports_conflict": true},
|
|
reportBundleBody(name, "incoming description", "# incoming", nil),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillConflict {
|
|
t.Fatalf("status = %s, want conflict", got.Status)
|
|
}
|
|
if got.Conflict == nil {
|
|
t.Fatal("expected conflict metadata")
|
|
}
|
|
if got.Conflict.ExistingSkillID != existingID {
|
|
t.Fatalf("existing_skill_id = %q, want %q", got.Conflict.ExistingSkillID, existingID)
|
|
}
|
|
if got.Conflict.ExistingCreatedBy != otherUserID {
|
|
t.Fatalf("existing_created_by = %q, want %q", got.Conflict.ExistingCreatedBy, otherUserID)
|
|
}
|
|
if got.Conflict.CanOverwrite {
|
|
t.Fatal("a non-creator must not be allowed to overwrite")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImport_OverwritePreservesIdentityAndBindings(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
name := fmt.Sprintf("overwrite-keep-%d", time.Now().UnixNano())
|
|
existingID := createImportTargetSkill(t, name, testUserID, map[string]string{
|
|
"keep.md": "old keep",
|
|
"prune.md": "should be removed",
|
|
})
|
|
bindAgentToSkill(t, existingID)
|
|
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": existingID},
|
|
reportBundleBody(name, "overwritten description", "# overwritten", map[string]string{"keep.md": "new keep"}),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillCompleted {
|
|
t.Fatalf("status = %s, want completed (error=%q)", got.Status, got.Error)
|
|
}
|
|
if got.Skill == nil {
|
|
t.Fatal("expected overwritten skill in response")
|
|
}
|
|
// Same row: UUID and creator preserved.
|
|
if got.Skill.ID != existingID {
|
|
t.Fatalf("overwrite must preserve UUID: got %q, want %q", got.Skill.ID, existingID)
|
|
}
|
|
if got.Skill.CreatedBy == nil || *got.Skill.CreatedBy != testUserID {
|
|
t.Fatalf("created_by not preserved: %v", got.Skill.CreatedBy)
|
|
}
|
|
if got.Skill.Description != "overwritten description" {
|
|
t.Fatalf("description not replaced: %q", got.Skill.Description)
|
|
}
|
|
// Files fully replaced: prune.md (absent from the new bundle) is gone.
|
|
if n := countSkillFiles(t, existingID); n != 1 {
|
|
t.Fatalf("expected 1 file after overwrite, got %d", n)
|
|
}
|
|
// Agent binding preserved — the agent must NOT need to re-add the skill.
|
|
if n := countAgentSkillBindings(t, existingID); n != 1 {
|
|
t.Fatalf("expected agent binding to survive overwrite, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImport_OverwriteNonCreatorFails(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
otherUserID := createRuntimeLocalSkillTestMember(t, "member")
|
|
name := fmt.Sprintf("overwrite-forbidden-%d", time.Now().UnixNano())
|
|
existingID := createImportTargetSkill(t, name, otherUserID, nil)
|
|
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": existingID},
|
|
reportBundleBody(name, "incoming description", "# incoming", nil),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillFailed {
|
|
t.Fatalf("status = %s, want failed", got.Status)
|
|
}
|
|
// Original skill (owned by someone else) must be untouched.
|
|
if _, desc, _, _ := getSkillRow(t, existingID); desc != "original description" {
|
|
t.Fatalf("forbidden overwrite must not mutate the skill, description = %q", desc)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImport_OverwriteTargetDeletedFails(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
name := fmt.Sprintf("overwrite-deleted-%d", time.Now().UnixNano())
|
|
deletedID := createImportTargetSkill(t, name, testUserID, nil)
|
|
// Simulate the target being deleted between the user's confirm and the
|
|
// daemon report.
|
|
if _, err := testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, deletedID); err != nil {
|
|
t.Fatalf("delete target skill: %v", err)
|
|
}
|
|
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": deletedID},
|
|
reportBundleBody(name, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillFailed {
|
|
t.Fatalf("status = %s, want failed", got.Status)
|
|
}
|
|
// Must NOT fall back to creating a new skill by name.
|
|
if n := countSkillsByName(t, name); n != 0 {
|
|
t.Fatalf("deleted-target overwrite must not create a skill, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImport_OverwriteRetryIsIdempotent(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
name := fmt.Sprintf("overwrite-idempotent-%d", time.Now().UnixNano())
|
|
existingID := createImportTargetSkill(t, name, testUserID, map[string]string{"old.md": "old"})
|
|
|
|
requestID := initiateLocalSkillImport(t, runtimeID, map[string]any{
|
|
"skill_key": "review-helper",
|
|
"action": "overwrite",
|
|
"target_skill_id": existingID,
|
|
})
|
|
|
|
// First report wins and overwrites the skill.
|
|
reportLocalSkillImport(t, runtimeID, requestID,
|
|
reportBundleBody(name, "first overwrite", "# first", map[string]string{"first.md": "1"}))
|
|
|
|
// A retry of the SAME request id with a different bundle must be ignored
|
|
// (the request is already terminal) — no second write.
|
|
reportLocalSkillImport(t, runtimeID, requestID,
|
|
reportBundleBody(name, "second overwrite", "# second", map[string]string{"second.md": "2", "extra.md": "3"}))
|
|
|
|
got := pollLocalSkillImport(t, runtimeID, requestID)
|
|
if got.Status != RuntimeLocalSkillCompleted {
|
|
t.Fatalf("status = %s, want completed", got.Status)
|
|
}
|
|
if _, desc, _, _ := getSkillRow(t, existingID); desc != "first overwrite" {
|
|
t.Fatalf("retry must not re-apply, description = %q", desc)
|
|
}
|
|
if n := countSkillFiles(t, existingID); n != 1 {
|
|
t.Fatalf("retry must not re-write files, got %d files", n)
|
|
}
|
|
}
|
|
|
|
// TestRuntimeLocalSkillImport_LegacyClientGetsFailedOnConflict verifies the
|
|
// installed-app compatibility gate: a client that does NOT opt into the
|
|
// structured-conflict contract keeps the legacy `failed` + "already exists"
|
|
// behavior on a same-name collision, instead of the new `conflict` status its
|
|
// older poll loop wouldn't understand.
|
|
func TestRuntimeLocalSkillImport_LegacyClientGetsFailedOnConflict(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
name := fmt.Sprintf("legacy-conflict-%d", time.Now().UnixNano())
|
|
createImportTargetSkill(t, name, testUserID, nil)
|
|
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
// No supports_conflict (and no action) — an old client.
|
|
map[string]any{"skill_key": "review-helper"},
|
|
reportBundleBody(name, "incoming description", "# incoming", nil),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillFailed {
|
|
t.Fatalf("status = %s, want failed (legacy contract)", got.Status)
|
|
}
|
|
if got.Conflict != nil {
|
|
t.Fatalf("legacy client must not receive structured conflict metadata: %+v", got.Conflict)
|
|
}
|
|
if got.Error != "a skill with this name already exists" {
|
|
t.Fatalf("error = %q, want legacy already-exists message", got.Error)
|
|
}
|
|
}
|
|
|
|
// TestRuntimeLocalSkillImport_OverwriteNameMismatchFails verifies the guard
|
|
// against a stale / wrong target_skill_id: if the target's name no longer
|
|
// matches the imported skill, the overwrite fails instead of writing one
|
|
// skill's content onto another.
|
|
func TestRuntimeLocalSkillImport_OverwriteNameMismatchFails(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
targetName := fmt.Sprintf("overwrite-target-%d", time.Now().UnixNano())
|
|
otherName := fmt.Sprintf("overwrite-other-%d", time.Now().UnixNano())
|
|
targetID := createImportTargetSkill(t, targetName, testUserID, nil)
|
|
|
|
// Overwrite targets targetID but the imported bundle is named otherName.
|
|
got := runLocalSkillImport(t, runtimeID,
|
|
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": targetID},
|
|
reportBundleBody(otherName, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
|
|
)
|
|
|
|
if got.Status != RuntimeLocalSkillFailed {
|
|
t.Fatalf("status = %s, want failed (name mismatch)", got.Status)
|
|
}
|
|
if _, desc, _, _ := getSkillRow(t, targetID); desc != "original description" {
|
|
t.Fatalf("name-mismatch overwrite must not mutate the target, description = %q", desc)
|
|
}
|
|
}
|