From 17c2f5180904d3d322abf44ee0c287fa19af4206 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 15 Apr 2026 10:03:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(skills):=20=E6=89=93=E9=80=9A=20sessio?= =?UTF-8?q?n/runtime/context=20=E6=8E=A5=E5=85=A5=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E9=99=8D=E7=BA=A7=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap.go | 13 + internal/app/bootstrap_test.go | 90 ++++++ internal/context/builder.go | 2 + internal/context/source_skills.go | 194 ++++++++++++ internal/context/source_skills_test.go | 87 ++++++ internal/context/types.go | 10 +- internal/runtime/events.go | 21 +- internal/runtime/run.go | 9 +- internal/runtime/runtime.go | 10 + internal/runtime/runtime_test.go | 3 + internal/runtime/session_mutation.go | 13 + internal/runtime/skills.go | 195 ++++++++++++ internal/runtime/skills_test.go | 294 ++++++++++++++++++ internal/session/skill_activation.go | 129 ++++++++ internal/session/skill_activation_test.go | 124 ++++++++ internal/session/store.go | 50 +-- internal/skills/registry.go | 9 + internal/skills/registry_test.go | 28 ++ internal/tui/bootstrap/builder_test.go | 25 ++ .../tui/core/app/update_permission_test.go | 12 + internal/tui/core/app/update_test.go | 12 + 21 files changed, 1297 insertions(+), 33 deletions(-) create mode 100644 internal/context/source_skills.go create mode 100644 internal/context/source_skills_test.go create mode 100644 internal/runtime/skills.go create mode 100644 internal/runtime/skills_test.go create mode 100644 internal/session/skill_activation.go create mode 100644 internal/session/skill_activation_test.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index a9df98a2..088d073c 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -3,6 +3,7 @@ package app import ( "context" "log" + "path/filepath" "strings" "time" @@ -19,6 +20,7 @@ import ( agentruntime "neo-code/internal/runtime" "neo-code/internal/security" agentsession "neo-code/internal/session" + "neo-code/internal/skills" "neo-code/internal/tools" "neo-code/internal/tools/bash" "neo-code/internal/tools/filesystem" @@ -165,6 +167,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er providerRegistry, contextBuilder, ) + runtimeSvc.SetSkillsRegistry(buildSkillsRegistry(ctx, loader.BaseDir())) // 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。 if memoSvc != nil && cfg.Memo.AutoExtract { @@ -259,6 +262,16 @@ func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error) return toolRegistry, mcpRegistry.Close, nil } +// buildSkillsRegistry 负责以最小代价初始化本地 skills registry,失败时返回 nil 并记录日志。 +func buildSkillsRegistry(ctx context.Context, baseDir string) skills.Registry { + root := filepath.Join(baseDir, "skills") + registry := skills.NewRegistry(skills.NewLocalLoader(root)) + if err := registry.Refresh(ctx); err != nil { + log.Printf("skills: initialize registry from %s failed: %v", root, err) + } + return registry +} + // buildMCPAgentExposureRules 将配置层的 agent 过滤规则转换为 tools/mcp 层输入。 func buildMCPAgentExposureRules(configs []config.MCPAgentExposureConfig) []mcp.AgentExposureRule { if len(configs) == 0 { diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index c8429c62..dd685c83 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -23,6 +23,8 @@ import ( "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + "neo-code/internal/skills" "neo-code/internal/tools" "neo-code/internal/tools/mcp" "neo-code/internal/tui" @@ -736,6 +738,94 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { } } +func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + if bundle.Runtime == nil { + t.Fatalf("expected runtime bundle to be created") + } + + runtimeWithSkills, ok := bundle.Runtime.(interface { + ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error + }) + if !ok { + t.Fatalf("expected runtime to expose ActivateSessionSkill") + } + + store := agentsession.NewStore(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir) + session := agentsession.New("missing root session") + if err := store.Save(context.Background(), &session); err != nil { + t.Fatalf("save session: %v", err) + } + + err = runtimeWithSkills.ActivateSessionSkill(context.Background(), session.ID, "missing") + if !errors.Is(err, skills.ErrSkillNotFound) { + t.Fatalf("expected ErrSkillNotFound with empty catalog, got %v", err) + } +} + +func TestBuildRuntimeInjectsSkillsRegistryWhenRootExists(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + skillsRoot := filepath.Join(home, ".neocode", "skills", "go-review") + if err := os.MkdirAll(skillsRoot, 0o755); err != nil { + t.Fatalf("mkdir skills root: %v", err) + } + if err := os.WriteFile(filepath.Join(skillsRoot, "SKILL.md"), []byte(strings.Join([]string{ + "---", + "id: go-review", + "name: go-review", + "---", + "", + "# Go Review", + "", + "Review code carefully.", + }, "\n")), 0o644); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + + store := agentsession.NewStore(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir) + session := agentsession.New("skill session") + if err := store.Save(context.Background(), &session); err != nil { + t.Fatalf("save session: %v", err) + } + + runtimeWithSkills, ok := bundle.Runtime.(interface { + ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error + }) + if !ok { + t.Fatalf("expected runtime to expose ActivateSessionSkill") + } + if err := runtimeWithSkills.ActivateSessionSkill(context.Background(), session.ID, "go-review"); err != nil { + t.Fatalf("ActivateSessionSkill() error = %v", err) + } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load session: %v", err) + } + if got := loaded.ActiveSkillIDs(); len(got) != 1 || got[0] != "go-review" { + t.Fatalf("expected activated skill persisted through injected registry, got %+v", got) + } +} + func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { disableBuiltinProviderAPIKeys(t) diff --git a/internal/context/builder.go b/internal/context/builder.go index 41eb9486..0aa0fb08 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -27,6 +27,7 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder { corePromptSource{}, &projectRulesSource{}, taskStateSource{}, + skillPromptSource{}, systemSource, }, trimPolicy: spanMessageTrimPolicy{}, @@ -42,6 +43,7 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou corePromptSource{}, &projectRulesSource{}, taskStateSource{}, + skillPromptSource{}, } if memoSource != nil { sources = append(sources, memoSource) diff --git a/internal/context/source_skills.go b/internal/context/source_skills.go new file mode 100644 index 00000000..3b270e3a --- /dev/null +++ b/internal/context/source_skills.go @@ -0,0 +1,194 @@ +package context + +import ( + "context" + "fmt" + "sort" + "strings" + + "neo-code/internal/skills" +) + +const ( + maxSkillReferences = 3 + maxSkillToolHints = 3 + maxSkillExamples = 2 +) + +// skillPromptSource 负责将当前轮次激活的 skills 渲染为统一的 prompt section。 +type skillPromptSource struct{} + +// Sections 根据 BuildInput.ActiveSkills 生成结构化的 Skills prompt section。 +func (skillPromptSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + rendered := renderActiveSkillsSection(input.ActiveSkills) + if strings.TrimSpace(rendered) == "" { + return nil, nil + } + return []promptSection{NewPromptSection("Skills", rendered)}, nil +} + +// renderActiveSkillsSection 负责去重、排序并渲染激活 skills 的结构化提示文本。 +func renderActiveSkillsSection(activeSkills []skills.Skill) string { + normalized := normalizeActiveSkills(activeSkills) + if len(normalized) == 0 { + return "" + } + + parts := make([]string, 0, len(normalized)) + for _, skill := range normalized { + rendered := renderOneSkill(skill) + if strings.TrimSpace(rendered) == "" { + continue + } + parts = append(parts, rendered) + } + return strings.Join(parts, "\n\n") +} + +// renderOneSkill 将单个 skill 渲染为固定结构,避免 provider 侧看到不稳定格式。 +func renderOneSkill(skill skills.Skill) string { + lines := []string{ + fmt.Sprintf("- skill: %s (%s)", strings.TrimSpace(skill.Descriptor.Name), strings.TrimSpace(skill.Descriptor.ID)), + } + + instruction := strings.TrimSpace(skill.Content.Instruction) + if instruction != "" { + lines = append(lines, " instruction: "+instruction) + } + + toolHints := truncateSkillStrings(skill.Content.ToolHints, maxSkillToolHints) + if len(toolHints) > 0 { + lines = append(lines, " tool_hints: "+strings.Join(toolHints, " | ")) + } + + references := truncateSkillReferences(skill.Content.References, maxSkillReferences) + if len(references) > 0 { + lines = append(lines, " references: "+strings.Join(references, " | ")) + } + + examples := truncateSkillStrings(skill.Content.Examples, maxSkillExamples) + if len(examples) > 0 { + lines = append(lines, " examples: "+strings.Join(examples, " | ")) + } + + return strings.Join(lines, "\n") +} + +// normalizeActiveSkills 对激活 skills 按规范化 ID 去重并稳定排序。 +func normalizeActiveSkills(activeSkills []skills.Skill) []skills.Skill { + if len(activeSkills) == 0 { + return nil + } + + byID := make(map[string]skills.Skill, len(activeSkills)) + keys := make([]string, 0, len(activeSkills)) + for _, skill := range activeSkills { + key := normalizeSkillID(skill.Descriptor.ID) + if key == "" { + continue + } + if _, ok := byID[key]; ok { + continue + } + byID[key] = skill + keys = append(keys, key) + } + if len(keys) == 0 { + return nil + } + + sort.Strings(keys) + normalized := make([]skills.Skill, 0, len(keys)) + for _, key := range keys { + normalized = append(normalized, byID[key]) + } + return normalized +} + +// truncateSkillStrings 对 skill 文本列表做去空、去重并按上限裁剪。 +func truncateSkillStrings(values []string, limit int) []string { + if len(values) == 0 || limit <= 0 { + return nil + } + + result := make([]string, 0, min(limit, len(values))) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + if len(result) >= limit { + break + } + } + return result +} + +// truncateSkillReferences 优先保留标题与摘要,并按固定上限裁剪引用条目。 +func truncateSkillReferences(references []skills.Reference, limit int) []string { + if len(references) == 0 || limit <= 0 { + return nil + } + + result := make([]string, 0, min(limit, len(references))) + seen := make(map[string]struct{}, len(references)) + for _, reference := range references { + title := strings.TrimSpace(reference.Title) + summary := strings.TrimSpace(reference.Summary) + path := strings.TrimSpace(reference.Path) + + var rendered string + switch { + case title != "" && summary != "": + rendered = title + ": " + summary + case title != "": + rendered = title + case summary != "": + rendered = summary + default: + rendered = path + } + rendered = strings.TrimSpace(rendered) + if rendered == "" { + continue + } + if _, ok := seen[rendered]; ok { + continue + } + seen[rendered] = struct{}{} + result = append(result, rendered) + if len(result) >= limit { + break + } + } + return result +} + +// normalizeSkillID 将 skill id 规范化为去重与排序使用的稳定键。 +func normalizeSkillID(skillID string) string { + normalized := strings.ToLower(strings.TrimSpace(skillID)) + if normalized == "" { + return "" + } + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + return strings.Trim(normalized, "-") +} + +// min 返回两个整数中的较小值,供固定上限裁剪逻辑复用。 +func min(a int, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/context/source_skills_test.go b/internal/context/source_skills_test.go new file mode 100644 index 00000000..f37da848 --- /dev/null +++ b/internal/context/source_skills_test.go @@ -0,0 +1,87 @@ +package context + +import ( + stdcontext "context" + "strings" + "testing" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/skills" +) + +func TestDefaultBuilderBuildInjectsSkillsInStableOrder(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + result, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + ActiveSkills: []skills.Skill{ + { + Descriptor: skills.Descriptor{ID: "zeta", Name: "Zeta"}, + Content: skills.Content{Instruction: "second"}, + }, + { + Descriptor: skills.Descriptor{ID: "go_review", Name: "Go Review"}, + Content: skills.Content{ + Instruction: "first", + ToolHints: []string{"read docs", "run tests", "inspect code", "open diff"}, + References: []skills.Reference{ + {Title: "Ref A", Summary: "summary-a"}, + {Title: "Ref B", Summary: "summary-b"}, + {Title: "Ref C", Summary: "summary-c"}, + {Title: "Ref D", Summary: "summary-d"}, + }, + Examples: []string{"example-1", "example-1", "example-2", "example-3"}, + }, + }, + { + Descriptor: skills.Descriptor{ID: "go-review", Name: "Go Review Duplicate"}, + Content: skills.Content{Instruction: "duplicate"}, + }, + }, + Metadata: testMetadata(t.TempDir()), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if !strings.Contains(result.SystemPrompt, "## Skills") { + t.Fatalf("expected skills section, got %q", result.SystemPrompt) + } + goReviewIndex := strings.Index(result.SystemPrompt, "go_review") + if goReviewIndex < 0 { + goReviewIndex = strings.Index(result.SystemPrompt, "go-review") + } + zetaIndex := strings.Index(result.SystemPrompt, "zeta") + if goReviewIndex < 0 || zetaIndex < 0 || goReviewIndex > zetaIndex { + t.Fatalf("expected normalized stable order, got %q", result.SystemPrompt) + } + if strings.Count(result.SystemPrompt, "- skill: Go Review") != 1 { + t.Fatalf("expected duplicate skill injection to be deduplicated, got %q", result.SystemPrompt) + } + if strings.Contains(result.SystemPrompt, "summary-d") { + t.Fatalf("expected references to be truncated, got %q", result.SystemPrompt) + } + if strings.Contains(result.SystemPrompt, "example-3") { + t.Fatalf("expected examples to be truncated, got %q", result.SystemPrompt) + } + if strings.Contains(result.SystemPrompt, "open diff") { + t.Fatalf("expected tool hints to be truncated, got %q", result.SystemPrompt) + } +} + +func TestDefaultBuilderBuildSkipsSkillsSectionWhenNoActiveSkills(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + result, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Metadata: testMetadata(t.TempDir()), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if strings.Contains(result.SystemPrompt, "## Skills") { + t.Fatalf("did not expect skills section without active skills, got %q", result.SystemPrompt) + } +} diff --git a/internal/context/types.go b/internal/context/types.go index 2a6b8884..ef37ee43 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -5,6 +5,7 @@ import ( providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" + "neo-code/internal/skills" "neo-code/internal/tools" ) @@ -15,10 +16,11 @@ type Builder interface { // BuildInput contains the runtime state needed to assemble model context. type BuildInput struct { - Messages []providertypes.Message - TaskState agentsession.TaskState - Metadata Metadata - Compact CompactOptions + Messages []providertypes.Message + TaskState agentsession.TaskState + ActiveSkills []skills.Skill + Metadata Metadata + Compact CompactOptions } // BuildResult is the provider-facing context produced for a single round. diff --git a/internal/runtime/events.go b/internal/runtime/events.go index a79eca47..af4a4a5f 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -29,12 +29,12 @@ type PhaseChangedPayload struct { To string `json:"to"` } -// BudgetCheckedPayload 为预算检查壳事件负载(1A 仅占位,1B阶段使用)。 +// BudgetCheckedPayload 为预算检查预留事件负载。 type BudgetCheckedPayload struct { Note string `json:"note,omitempty"` } -// ProgressEvaluatedPayload 汇总 progress 控制面评估结果。 +// ProgressEvaluatedPayload 汇总 progress 控制面的评估结果。 type ProgressEvaluatedPayload struct { Score controlplane.ProgressScore `json:"score"` } @@ -45,7 +45,7 @@ type StopReasonDecidedPayload struct { Detail string `json:"detail,omitempty"` } -// LedgerReconciledPayload 为账本对账壳事件负载(1A 仅占位)。 +// LedgerReconciledPayload 为账本对账预留事件负载。 type LedgerReconciledPayload struct { Note string `json:"note,omitempty"` } @@ -83,6 +83,11 @@ type PermissionResolvedPayload struct { ResolvedAs string } +// SessionSkillEventPayload 描述一次会话级 skill 状态变化或缺失提示。 +type SessionSkillEventPayload struct { + SkillID string `json:"skill_id"` +} + const ( // EventUserMessage is emitted after the user input has been accepted and saved. EventUserMessage EventType = "user_message" @@ -106,18 +111,24 @@ const ( // EventProviderRetry is emitted when runtime retries a provider call due to // a retryable error (e.g. 429, 5xx). Payload is a human-readable message. EventProviderRetry EventType = "provider_retry" - // EventPermissionRequested 是 1A 权限请求事件名。 + // EventPermissionRequested 表示一次权限审批请求。 EventPermissionRequested EventType = "permission_requested" // EventPermissionResolved is emitted when runtime resolves a permission request or denial. EventPermissionResolved EventType = "permission_resolved" // EventCompactStart is emitted when a compact cycle starts. EventCompactStart EventType = "compact_start" - // EventCompactApplied 表示一次 compact 已成功应用或校验完成(1A 主事件)。 + // EventCompactApplied 表示一次 compact 已成功应用或校验完成。 EventCompactApplied EventType = "compact_applied" // EventCompactError is emitted when compact fails. EventCompactError EventType = "compact_error" // EventTokenUsage is emitted after each provider response with token statistics. EventTokenUsage EventType = "token_usage" + // EventSkillActivated 表示会话成功激活了一个 skill。 + EventSkillActivated EventType = "skill_activated" + // EventSkillDeactivated 表示会话成功停用了一个 skill。 + EventSkillDeactivated EventType = "skill_deactivated" + // EventSkillMissing 表示运行时发现会话记录的 skill 已无法解析。 + EventSkillMissing EventType = "skill_missing" // EventPhaseChanged 表示显式 phase 迁移。 EventPhaseChanged EventType = "phase_changed" // EventProgressEvaluated 表示 progress 评估结果。 diff --git a/internal/runtime/run.go b/internal/runtime/run.go index ba5c13f8..a56cda08 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -171,10 +171,15 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (turnSnapshot, bool, error) { cfg := s.configManager.Get() activeWorkdir := agentsession.EffectiveWorkdir(state.session.Workdir, cfg.Workdir) + activeSkills, err := s.resolveActiveSkills(ctx, state) + if err != nil { + return turnSnapshot{}, false, err + } builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ - Messages: state.session.Messages, - TaskState: state.session.TaskState, + Messages: state.session.Messages, + TaskState: state.session.TaskState, + ActiveSkills: activeSkills, Metadata: agentcontext.Metadata{ Workdir: activeWorkdir, Shell: cfg.Shell, diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 1a345f5a..d965f312 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -12,6 +12,7 @@ import ( providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/approval" agentsession "neo-code/internal/session" + "neo-code/internal/skills" "neo-code/internal/tools" ) @@ -34,6 +35,9 @@ type Runtime interface { Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]agentsession.Summary, error) LoadSession(ctx context.Context, id string) (agentsession.Session, error) + ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error + DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error + ListSessionSkills(ctx context.Context, sessionID string) ([]SessionSkillState, error) } // UserInput 描述一次用户输入请求的最小运行参数。 @@ -66,6 +70,7 @@ type Service struct { compactRunner contextcompact.Runner approvalBroker *approval.Broker memoExtractor MemoExtractor + skillsRegistry skills.Registry events chan RuntimeEvent sessionMu sync.Mutex @@ -127,6 +132,11 @@ func (s *Service) SetMemoExtractor(extractor MemoExtractor) { s.memoExtractor = extractor } +// SetSkillsRegistry 设置运行时可选的 skills registry,用于激活校验与上下文注入。 +func (s *Service) SetSkillsRegistry(registry skills.Registry) { + s.skillsRegistry = registry +} + // CancelActiveRun 尝试取消最近一次仍在执行的 Run。 func (s *Service) CancelActiveRun() bool { s.runMu.Lock() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 72837909..7de08a4f 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -22,6 +22,7 @@ import ( "neo-code/internal/runtime/controlplane" "neo-code/internal/security" agentsession "neo-code/internal/session" + "neo-code/internal/skills" "neo-code/internal/tools" ) @@ -3009,6 +3010,7 @@ func cloneSession(session agentsession.Session) agentsession.Session { cloned := session cloned.Messages = append([]providertypes.Message(nil), session.Messages...) cloned.TaskState = session.TaskState.Clone() + cloned.ActivatedSkills = append([]agentsession.SkillActivation(nil), session.ActivatedSkills...) return cloned } @@ -3023,6 +3025,7 @@ func cloneBuildInput(input agentcontext.BuildInput) agentcontext.BuildInput { cloned := input cloned.Messages = append([]providertypes.Message(nil), input.Messages...) cloned.TaskState = input.TaskState.Clone() + cloned.ActiveSkills = append([]skills.Skill(nil), input.ActiveSkills...) return cloned } diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index 88a0f2b8..bf914917 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -74,10 +74,23 @@ func cloneSessionForPersistence(session agentsession.Session) agentsession.Sessi cloned := session cloned.Messages = cloneMessagesForPersistence(session.Messages) cloned.TaskState = session.TaskState.Clone() + cloned.ActivatedSkills = agentsessionCloneSkillActivations(session.ActivatedSkills) cloned.Todos = cloneTodosForPersistence(session.Todos) return cloned } +// agentsessionCloneSkillActivations 深拷贝会话中的 skill 激活列表,避免持久化阶段共享底层切片。 +func agentsessionCloneSkillActivations(items []agentsession.SkillActivation) []agentsession.SkillActivation { + if len(items) == 0 { + return nil + } + cloned := make([]agentsession.SkillActivation, len(items)) + for idx, item := range items { + cloned[idx] = item.Clone() + } + return cloned +} + // cloneMessagesForPersistence 深拷贝消息切片及其嵌套字段,确保 Save 期间读取稳定。 func cloneMessagesForPersistence(messages []providertypes.Message) []providertypes.Message { if len(messages) == 0 { diff --git a/internal/runtime/skills.go b/internal/runtime/skills.go new file mode 100644 index 00000000..f2298f19 --- /dev/null +++ b/internal/runtime/skills.go @@ -0,0 +1,195 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "time" + + agentsession "neo-code/internal/session" + "neo-code/internal/skills" +) + +var errSkillsRegistryUnavailable = errors.New("runtime: skills registry unavailable") + +// SessionSkillState 描述一个会话中 skill 的解析结果与当前状态。 +type SessionSkillState struct { + SkillID string + Missing bool + Descriptor *skills.Descriptor +} + +// ActivateSessionSkill 在 session 级激活一个已注册的 skill。 +func (s *Service) ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(sessionID) == "" { + return errors.New("runtime: session id is empty") + } + if s.skillsRegistry == nil { + return errSkillsRegistryUnavailable + } + + descriptor, _, err := s.skillsRegistry.Get(ctx, skillID) + if err != nil { + return err + } + + session, changed, err := s.mutateSessionSkills(ctx, sessionID, func(current *agentsession.Session) bool { + return current.ActivateSkill(descriptor.ID) + }) + if err != nil { + return err + } + if changed { + _ = s.emit(ctx, EventSkillActivated, "", session.ID, SessionSkillEventPayload{SkillID: descriptor.ID}) + } + return nil +} + +// DeactivateSessionSkill 在 session 级停用一个 skill,未知 skill 也保持幂等成功。 +func (s *Service) DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(sessionID) == "" { + return errors.New("runtime: session id is empty") + } + + session, changed, err := s.mutateSessionSkills(ctx, sessionID, func(current *agentsession.Session) bool { + return current.DeactivateSkill(skillID) + }) + if err != nil { + return err + } + if changed { + _ = s.emit(ctx, EventSkillDeactivated, "", session.ID, SessionSkillEventPayload{SkillID: normalizeRuntimeSkillID(skillID)}) + } + return nil +} + +// ListSessionSkills 返回当前 session 激活 skills 的解析视图。 +func (s *Service) ListSessionSkills(ctx context.Context, sessionID string) ([]SessionSkillState, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if strings.TrimSpace(sessionID) == "" { + return nil, errors.New("runtime: session id is empty") + } + + session, err := s.sessionStore.Load(ctx, sessionID) + if err != nil { + return nil, err + } + + ids := session.ActiveSkillIDs() + if len(ids) == 0 { + return nil, nil + } + + states := make([]SessionSkillState, 0, len(ids)) + for _, skillID := range ids { + state := SessionSkillState{SkillID: skillID} + if s.skillsRegistry == nil { + state.Missing = true + states = append(states, state) + continue + } + + descriptor, _, err := s.skillsRegistry.Get(ctx, skillID) + if err != nil { + if errors.Is(err, skills.ErrSkillNotFound) { + state.Missing = true + states = append(states, state) + continue + } + return nil, err + } + descriptorCopy := descriptor + state.Descriptor = &descriptorCopy + states = append(states, state) + } + return states, nil +} + +// resolveActiveSkills 解析当前 session 激活的 skills,并对缺失项做事件降级。 +func (s *Service) resolveActiveSkills(ctx context.Context, state *runState) ([]skills.Skill, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if state == nil { + return nil, nil + } + + activeSkillIDs := state.session.ActiveSkillIDs() + if len(activeSkillIDs) == 0 { + return nil, nil + } + if s.skillsRegistry == nil { + for _, skillID := range activeSkillIDs { + _ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID}) + } + return nil, nil + } + + resolved := make([]skills.Skill, 0, len(activeSkillIDs)) + for _, skillID := range activeSkillIDs { + descriptor, content, err := s.skillsRegistry.Get(ctx, skillID) + if err != nil { + if errors.Is(err, skills.ErrSkillNotFound) { + _ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID}) + continue + } + return nil, err + } + resolved = append(resolved, skills.Skill{ + Descriptor: descriptor, + Content: content, + }) + } + return resolved, nil +} + +// mutateSessionSkills 串行修改 session 的激活 skills,并在发生变化时立即持久化。 +func (s *Service) mutateSessionSkills( + ctx context.Context, + sessionID string, + mutate func(current *agentsession.Session) bool, +) (agentsession.Session, bool, error) { + if mutate == nil { + return agentsession.Session{}, false, errors.New("runtime: mutate function is nil") + } + + sessionMu, releaseLockRef := s.acquireSessionLock(sessionID) + sessionMu.Lock() + defer func() { + sessionMu.Unlock() + releaseLockRef() + }() + + session, err := s.sessionStore.Load(ctx, sessionID) + if err != nil { + return agentsession.Session{}, false, err + } + if !mutate(&session) { + return session, false, nil + } + + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + return agentsession.Session{}, false, err + } + return session, true, nil +} + +// normalizeRuntimeSkillID 统一 runtime 层事件与持久化使用的 skill id 规范化方式。 +func normalizeRuntimeSkillID(skillID string) string { + normalized := strings.ToLower(strings.TrimSpace(skillID)) + if normalized == "" { + return "" + } + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + return strings.Trim(normalized, "-") +} diff --git a/internal/runtime/skills_test.go b/internal/runtime/skills_test.go new file mode 100644 index 00000000..61b58d0c --- /dev/null +++ b/internal/runtime/skills_test.go @@ -0,0 +1,294 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + + "neo-code/internal/config" + agentcontext "neo-code/internal/context" + contextcompact "neo-code/internal/context/compact" + providertypes "neo-code/internal/provider/types" + "neo-code/internal/skills" + "neo-code/internal/tools" +) + +type stubSkillsRegistry struct { + skills map[string]skills.Skill + getErr error +} + +func (r *stubSkillsRegistry) List(ctx context.Context, input skills.ListInput) ([]skills.Descriptor, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + result := make([]skills.Descriptor, 0, len(r.skills)) + for _, skill := range r.skills { + result = append(result, skill.Descriptor) + } + return result, nil +} + +func (r *stubSkillsRegistry) Get(ctx context.Context, id string) (skills.Descriptor, skills.Content, error) { + if err := ctx.Err(); err != nil { + return skills.Descriptor{}, skills.Content{}, err + } + if r.getErr != nil { + return skills.Descriptor{}, skills.Content{}, r.getErr + } + for _, skill := range r.skills { + if normalizeRuntimeSkillID(skill.Descriptor.ID) == normalizeRuntimeSkillID(id) { + return skill.Descriptor, skill.Content, nil + } + } + return skills.Descriptor{}, skills.Content{}, fmt.Errorf("%w: %s", skills.ErrSkillNotFound, id) +} + +func (r *stubSkillsRegistry) Refresh(ctx context.Context) error { + return ctx.Err() +} + +func TestActivateSessionSkillPersistsAndEmitsEvent(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-activate-skill") + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + service.SetSkillsRegistry(&stubSkillsRegistry{ + skills: map[string]skills.Skill{ + "go-review": { + Descriptor: skills.Descriptor{ID: "go-review", Name: "Go Review"}, + Content: skills.Content{Instruction: "review code"}, + }, + }, + }) + + if err := service.ActivateSessionSkill(context.Background(), session.ID, "go_review"); err != nil { + t.Fatalf("ActivateSessionSkill() error = %v", err) + } + + loaded := store.sessions[session.ID] + if got := loaded.ActiveSkillIDs(); len(got) != 1 || got[0] != "go-review" { + t.Fatalf("expected activated skill persisted, got %+v", got) + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 || events[0].Type != EventSkillActivated { + t.Fatalf("expected skill_activated event, got %+v", events) + } + payload, ok := events[0].Payload.(SessionSkillEventPayload) + if !ok || payload.SkillID != "go-review" { + t.Fatalf("unexpected event payload: %+v", events[0].Payload) + } +} + +func TestActivateSessionSkillRejectsMissingSkill(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-activate-missing") + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + service.SetSkillsRegistry(&stubSkillsRegistry{skills: map[string]skills.Skill{}}) + + if err := service.ActivateSessionSkill(context.Background(), session.ID, "missing"); err == nil { + t.Fatalf("expected missing skill activation to fail") + } + if got := store.sessions[session.ID].ActiveSkillIDs(); len(got) != 0 { + t.Fatalf("expected session to remain clean, got %+v", got) + } +} + +func TestDeactivateSessionSkillIsIdempotentForUnknownSkill(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-deactivate-skill") + session.ActivateSkill("go-review") + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + if err := service.DeactivateSessionSkill(context.Background(), session.ID, "missing"); err != nil { + t.Fatalf("DeactivateSessionSkill() error = %v", err) + } + if got := store.sessions[session.ID].ActiveSkillIDs(); len(got) != 1 || got[0] != "go-review" { + t.Fatalf("expected unchanged activations, got %+v", got) + } +} + +func TestPrepareTurnSnapshotPassesResolvedSkillsToContextBuilder(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-build-skill") + session.ActivateSkill("go-review") + store.sessions[session.ID] = cloneSession(session) + + builder := &stubContextBuilder{} + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, builder) + service.SetSkillsRegistry(&stubSkillsRegistry{ + skills: map[string]skills.Skill{ + "go-review": { + Descriptor: skills.Descriptor{ID: "go-review", Name: "Go Review"}, + Content: skills.Content{Instruction: "review code"}, + }, + }, + }) + + state := newRunState("run-build-skill", session) + if _, rebuilt, err := service.prepareTurnSnapshot(context.Background(), &state); err != nil { + t.Fatalf("prepareTurnSnapshot() error = %v", err) + } else if rebuilt { + t.Fatalf("did not expect snapshot rebuild") + } + if len(builder.lastInput.ActiveSkills) != 1 || builder.lastInput.ActiveSkills[0].Descriptor.ID != "go-review" { + t.Fatalf("expected active skills forwarded to context builder, got %+v", builder.lastInput.ActiveSkills) + } +} + +func TestPrepareTurnSnapshotEmitsSkillMissingAndContinues(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-missing-skill") + session.ActivateSkill("missing-skill") + store.sessions[session.ID] = cloneSession(session) + + builder := &stubContextBuilder{} + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, builder) + + state := newRunState("run-missing-skill", session) + if _, rebuilt, err := service.prepareTurnSnapshot(context.Background(), &state); err != nil { + t.Fatalf("prepareTurnSnapshot() error = %v", err) + } else if rebuilt { + t.Fatalf("did not expect snapshot rebuild") + } + if len(builder.lastInput.ActiveSkills) != 0 { + t.Fatalf("expected missing skill to be skipped, got %+v", builder.lastInput.ActiveSkills) + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 || events[0].Type != EventSkillMissing { + t.Fatalf("expected skill_missing event, got %+v", events) + } +} + +func TestPrepareTurnSnapshotPropagatesRegistryFailure(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-skill-registry-failure") + session.ActivateSkill("go-review") + store.sessions[session.ID] = cloneSession(session) + + builder := &stubContextBuilder{} + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, builder) + service.SetSkillsRegistry(&stubSkillsRegistry{getErr: os.ErrPermission}) + + state := newRunState("run-skill-registry-failure", session) + if _, _, err := service.prepareTurnSnapshot(context.Background(), &state); !errors.Is(err, os.ErrPermission) { + t.Fatalf("expected registry failure to propagate, got %v", err) + } + if len(collectRuntimeEvents(service.Events())) != 0 { + t.Fatalf("expected no skill_missing event on registry failure") + } +} + +func TestListSessionSkillsPropagatesRegistryFailure(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-list-skill-registry-failure") + session.ActivateSkill("go-review") + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + service.SetSkillsRegistry(&stubSkillsRegistry{getErr: os.ErrPermission}) + + if _, err := service.ListSessionSkills(context.Background(), session.ID); !errors.Is(err, os.ErrPermission) { + t.Fatalf("expected registry failure to propagate, got %v", err) + } +} + +func TestServiceRunReinjectsSkillsAfterAutoCompact(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.AutoCompact.Enabled = true + cfg.Context.AutoCompact.InputTokenThreshold = 1 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := newRuntimeSession("session-auto-compact-skills") + session.ActivateSkill("go-review") + session.TokenInputTotal = 3 + store.sessions[session.ID] = cloneSession(session) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), + AutoCompactSuggested: input.Metadata.SessionInputTokens >= 1, + }, nil + }, + } + compactRunner := &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + return contextcompact.Result{ + Applied: true, + Messages: append([]providertypes.Message(nil), input.Messages...), + TaskState: input.TaskState.Clone(), + Metrics: contextcompact.Metrics{ + TriggerMode: string(contextcompact.ModeAuto), + }, + }, nil + }, + } + scripted := &scriptedProvider{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("done")}, + }, + } + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + service.compactRunner = compactRunner + service.SetSkillsRegistry(&stubSkillsRegistry{ + skills: map[string]skills.Skill{ + "go-review": { + Descriptor: skills.Descriptor{ID: "go-review", Name: "Go Review"}, + Content: skills.Content{Instruction: "review code"}, + }, + }, + }) + + if err := service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-auto-compact-skills", Content: "hello"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(builder.builds) < 2 { + t.Fatalf("expected context builder to run before and after compact, got %d", len(builder.builds)) + } + for idx, build := range builder.builds[:2] { + if len(build.ActiveSkills) != 1 || build.ActiveSkills[0].Descriptor.ID != "go-review" { + t.Fatalf("expected active skill on build %d, got %+v", idx, build.ActiveSkills) + } + } +} diff --git a/internal/session/skill_activation.go b/internal/session/skill_activation.go new file mode 100644 index 00000000..fb0fc2b1 --- /dev/null +++ b/internal/session/skill_activation.go @@ -0,0 +1,129 @@ +package session + +import ( + "sort" + "strings" +) + +// SkillActivation 表示一个会话级激活的 skill 引用,仅持久化规范化后的 SkillID。 +type SkillActivation struct { + SkillID string `json:"skill_id"` +} + +// ActivateSkill 将 skill 记录到当前会话,并返回本次调用是否新增了激活项。 +func (s *Session) ActivateSkill(skillID string) bool { + if s == nil { + return false + } + + normalized := normalizeSkillID(skillID) + if normalized == "" { + return false + } + for _, item := range s.ActivatedSkills { + if item.SkillID == normalized { + return false + } + } + s.ActivatedSkills = append(s.ActivatedSkills, SkillActivation{SkillID: normalized}) + s.ActivatedSkills = normalizeSkillActivations(s.ActivatedSkills) + return true +} + +// DeactivateSkill 从当前会话移除一个 skill,并返回本次调用是否真的移除了记录。 +func (s *Session) DeactivateSkill(skillID string) bool { + if s == nil || len(s.ActivatedSkills) == 0 { + return false + } + + normalized := normalizeSkillID(skillID) + if normalized == "" { + return false + } + + filtered := make([]SkillActivation, 0, len(s.ActivatedSkills)) + removed := false + for _, item := range s.ActivatedSkills { + if item.SkillID == normalized { + removed = true + continue + } + filtered = append(filtered, item) + } + if !removed { + return false + } + s.ActivatedSkills = normalizeSkillActivations(filtered) + return true +} + +// ActiveSkillIDs 返回当前会话中已激活 skill 的稳定、去重后的 ID 列表。 +func (s Session) ActiveSkillIDs() []string { + if len(s.ActivatedSkills) == 0 { + return nil + } + + normalized := normalizeSkillActivations(s.ActivatedSkills) + ids := make([]string, 0, len(normalized)) + for _, item := range normalized { + ids = append(ids, item.SkillID) + } + return ids +} + +// Clone 返回 skill 激活记录的副本,避免调用方共享底层切片。 +func (a SkillActivation) Clone() SkillActivation { + return SkillActivation{SkillID: a.SkillID} +} + +// normalizeSkillActivations 统一收敛 skill 激活列表的空白、重复项与排序。 +func normalizeSkillActivations(items []SkillActivation) []SkillActivation { + if len(items) == 0 { + return nil + } + + deduped := make([]SkillActivation, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + normalized := normalizeSkillID(item.SkillID) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + deduped = append(deduped, SkillActivation{SkillID: normalized}) + } + if len(deduped) == 0 { + return nil + } + + sort.Slice(deduped, func(i, j int) bool { + return deduped[i].SkillID < deduped[j].SkillID + }) + return deduped +} + +// cloneSkillActivations 深拷贝 skill 激活列表,供运行时与持久化快照复用。 +func cloneSkillActivations(items []SkillActivation) []SkillActivation { + if len(items) == 0 { + return nil + } + cloned := make([]SkillActivation, len(items)) + for idx, item := range items { + cloned[idx] = item.Clone() + } + return cloned +} + +// normalizeSkillID 将外部输入的 skill id 规范化为稳定的会话持久化键。 +func normalizeSkillID(skillID string) string { + normalized := strings.ToLower(strings.TrimSpace(skillID)) + if normalized == "" { + return "" + } + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + return strings.Trim(normalized, "-") +} diff --git a/internal/session/skill_activation_test.go b/internal/session/skill_activation_test.go new file mode 100644 index 00000000..034071e1 --- /dev/null +++ b/internal/session/skill_activation_test.go @@ -0,0 +1,124 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + providertypes "neo-code/internal/provider/types" +) + +func TestSessionSkillActivationHelpers(t *testing.T) { + t.Parallel() + + session := New("skills") + if !session.ActivateSkill(" Go_Review ") { + t.Fatalf("expected first activation to report change") + } + if session.ActivateSkill("go-review") { + t.Fatalf("expected duplicate activation to be idempotent") + } + if !session.ActivateSkill("zeta") { + t.Fatalf("expected second activation to report change") + } + if got := session.ActiveSkillIDs(); len(got) != 2 || got[0] != "go-review" || got[1] != "zeta" { + t.Fatalf("unexpected active skill ids: %+v", got) + } + if !session.DeactivateSkill("GO_REVIEW") { + t.Fatalf("expected deactivate to remove normalized id") + } + if session.DeactivateSkill("go-review") { + t.Fatalf("expected duplicate deactivate to be idempotent") + } + if got := session.ActiveSkillIDs(); len(got) != 1 || got[0] != "zeta" { + t.Fatalf("unexpected active skill ids after deactivate: %+v", got) + } +} + +func TestJSONStoreSaveLoadRoundTripActivatedSkills(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + session := &Session{ + SchemaVersion: CurrentSchemaVersion, + ID: "skills-round-trip", + Title: "Skills Round Trip", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + TaskState: TaskState{}, + ActivatedSkills: []SkillActivation{ + {SkillID: " zeta "}, + {SkillID: "go_review"}, + {SkillID: "go-review"}, + }, + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + } + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save session with activated skills: %v", err) + } + if got := session.ActiveSkillIDs(); len(got) != 2 || got[0] != "go-review" || got[1] != "zeta" { + t.Fatalf("expected normalized in-memory activations, got %+v", got) + } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load session with activated skills: %v", err) + } + if got := loaded.ActiveSkillIDs(); len(got) != 2 || got[0] != "go-review" || got[1] != "zeta" { + t.Fatalf("expected normalized loaded activations, got %+v", got) + } + + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), session.ID+".json") + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatalf("read saved session: %v", err) + } + if !strings.Contains(string(raw), "\"activated_skills\"") { + t.Fatalf("expected persisted activated_skills field, got:\n%s", string(raw)) + } +} + +func TestJSONStoreLoadAllowsMissingActivatedSkillsField(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "no-activated-skills.json"), strings.Join([]string{ + `{`, + ` "schema_version": 1,`, + ` "id": "no-activated-skills",`, + ` "title": "No Activated Skills",`, + ` "created_at": "2026-04-15T10:00:00Z",`, + ` "updated_at": "2026-04-15T10:05:00Z",`, + ` "task_state": {`, + ` "goal": "",`, + ` "progress": [],`, + ` "open_items": [],`, + ` "next_step": "",`, + ` "blockers": [],`, + ` "key_artifacts": [],`, + ` "decisions": [],`, + ` "user_constraints": [],`, + ` "last_updated_at": "2026-04-15T10:05:00Z"`, + ` },`, + ` "messages": []`, + `}`, + }, "\n")) + + loaded, err := store.Load(context.Background(), "no-activated-skills") + if err != nil { + t.Fatalf("load session without activated_skills field: %v", err) + } + if len(loaded.ActiveSkillIDs()) != 0 { + t.Fatalf("expected no activated skills, got %+v", loaded.ActiveSkillIDs()) + } +} diff --git a/internal/session/store.go b/internal/session/store.go index 35a8cab4..afac829f 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -31,6 +31,7 @@ type Session struct { UpdatedAt time.Time `json:"updated_at"` Workdir string `json:"workdir,omitempty"` TaskState TaskState `json:"task_state"` + ActivatedSkills []SkillActivation `json:"activated_skills,omitempty"` Todos []TodoItem `json:"todos,omitempty"` Messages []providertypes.Message `json:"messages"` TokenInputTotal int `json:"token_input_total,omitempty"` @@ -83,6 +84,7 @@ func (s *JSONStore) Save(ctx context.Context, session *Session) error { } session.TaskState = normalizeAndClampTaskState(session.TaskState) + session.ActivatedSkills = normalizeSkillActivations(session.ActivatedSkills) normalizedTodos, err := normalizeAndValidateTodos(session.Todos) if err != nil { return err @@ -204,15 +206,16 @@ func New(title string) Session { func NewWithWorkdir(title string, workdir string) Session { now := time.Now() return Session{ - SchemaVersion: CurrentSchemaVersion, - ID: NewID("session"), - Title: sanitizeTitle(title), - CreatedAt: now, - UpdatedAt: now, - Workdir: strings.TrimSpace(workdir), - TaskState: TaskState{}, - Todos: []TodoItem{}, - Messages: []providertypes.Message{}, + SchemaVersion: CurrentSchemaVersion, + ID: NewID("session"), + Title: sanitizeTitle(title), + CreatedAt: now, + UpdatedAt: now, + Workdir: strings.TrimSpace(workdir), + TaskState: TaskState{}, + ActivatedSkills: []SkillActivation{}, + Todos: []TodoItem{}, + Messages: []providertypes.Message{}, } } @@ -244,19 +247,20 @@ func validateSessionSchema(session Session) error { // decodeStoredSession 严格校验持久化会话所需字段,并拒绝缺少 schema_version 或 task_state 的旧数据。 func decodeStoredSession(data []byte) (Session, error) { type storedSession struct { - SchemaVersion *int `json:"schema_version"` - ID string `json:"id"` - Title string `json:"title"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"workdir,omitempty"` - TaskState *TaskState `json:"task_state"` - Todos []TodoItem `json:"todos,omitempty"` - Messages []providertypes.Message `json:"messages"` - TokenInput int `json:"token_input_total,omitempty"` - TokenOutput int `json:"token_output_total,omitempty"` + SchemaVersion *int `json:"schema_version"` + ID string `json:"id"` + Title string `json:"title"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"workdir,omitempty"` + TaskState *TaskState `json:"task_state"` + ActivatedSkills []SkillActivation `json:"activated_skills,omitempty"` + Todos []TodoItem `json:"todos,omitempty"` + Messages []providertypes.Message `json:"messages"` + TokenInput int `json:"token_input_total,omitempty"` + TokenOutput int `json:"token_output_total,omitempty"` } var stored storedSession @@ -281,6 +285,7 @@ func decodeStoredSession(data []byte) (Session, error) { UpdatedAt: stored.UpdatedAt, Workdir: stored.Workdir, TaskState: *stored.TaskState, + ActivatedSkills: stored.ActivatedSkills, Todos: stored.Todos, Messages: stored.Messages, TokenInputTotal: stored.TokenInput, @@ -290,6 +295,7 @@ func decodeStoredSession(data []byte) (Session, error) { return Session{}, err } session.TaskState = normalizeAndClampTaskState(session.TaskState) + session.ActivatedSkills = normalizeSkillActivations(session.ActivatedSkills) normalizedTodos, err := normalizeAndValidateTodos(session.Todos) if err != nil { return Session{}, err diff --git a/internal/skills/registry.go b/internal/skills/registry.go index 385aef0c..a0d38957 100644 --- a/internal/skills/registry.go +++ b/internal/skills/registry.go @@ -2,6 +2,7 @@ package skills import ( "context" + "errors" "fmt" "sort" "strings" @@ -41,6 +42,14 @@ func (r *MemoryRegistry) Refresh(ctx context.Context) error { snapshot, err := r.loader.Load(ctx) if err != nil { + if errors.Is(err, ErrSkillRootNotFound) { + r.mu.Lock() + r.loaded = true + r.byID = map[string]Skill{} + r.issues = nil + r.mu.Unlock() + return nil + } r.mu.Lock() r.issues = []LoadIssue{{ Code: IssueRefreshFailed, diff --git a/internal/skills/registry_test.go b/internal/skills/registry_test.go index 4e9a3ecf..4ee4afda 100644 --- a/internal/skills/registry_test.go +++ b/internal/skills/registry_test.go @@ -2,6 +2,7 @@ package skills import ( "context" + "errors" "os" "path/filepath" "strings" @@ -291,6 +292,33 @@ func TestMemoryRegistryRefreshFailure(t *testing.T) { } } +func TestMemoryRegistryTreatsMissingRootAsEmptyCatalog(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "missing-skills-root") + registry := NewRegistry(NewLocalLoader(root)) + + if err := registry.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + list, err := registry.List(context.Background(), ListInput{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(list) != 0 { + t.Fatalf("expected empty list, got %+v", list) + } + if len(registry.Issues()) != 0 { + t.Fatalf("expected no issues for missing root, got %+v", registry.Issues()) + } + + _, _, err = registry.Get(context.Background(), "missing") + if !errors.Is(err, ErrSkillNotFound) { + t.Fatalf("expected ErrSkillNotFound, got %v", err) + } +} + func TestMemoryRegistryEnsureLoadedRetriesAfterFailure(t *testing.T) { t.Parallel() diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 1fe12c2f..7a688b78 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -10,6 +10,7 @@ import ( providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" + "neo-code/internal/skills" ) type testRuntime struct{} @@ -44,6 +45,18 @@ func (r *testRuntime) LoadSession(ctx context.Context, id string) (agentsession. return agentsession.Session{}, nil } +func (r *testRuntime) ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (r *testRuntime) DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (r *testRuntime) ListSessionSkills(ctx context.Context, sessionID string) ([]agentruntime.SessionSkillState, error) { + return []agentruntime.SessionSkillState{{SkillID: "test", Descriptor: &skills.Descriptor{ID: "test"}}}, nil +} + type testProviderService struct{} func (s *testProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { @@ -223,6 +236,18 @@ func (r noopRuntime) LoadSession(ctx context.Context, id string) (agentsession.S return agentsession.Session{}, nil } +func (r noopRuntime) ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (r noopRuntime) DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (r noopRuntime) ListSessionSkills(ctx context.Context, sessionID string) ([]agentruntime.SessionSkillState, error) { + return nil, nil +} + type noopProviderService struct{} func (s noopProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index 9ea6528f..30b0469b 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -54,6 +54,18 @@ func (r *permissionTestRuntime) LoadSession(ctx context.Context, id string) (age return agentsession.Session{}, nil } +func (r *permissionTestRuntime) ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (r *permissionTestRuntime) DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (r *permissionTestRuntime) ListSessionSkills(ctx context.Context, sessionID string) ([]agentruntime.SessionSkillState, error) { + return nil, nil +} + func newPermissionTestApp(runtime agentruntime.Runtime) *App { input := textarea.New() spin := spinner.New() diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 661b8c35..389e25f2 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -96,6 +96,18 @@ func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession. return agentsession.NewWithWorkdir("draft", ""), nil } +func (s *stubRuntime) ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (s *stubRuntime) DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error { + return nil +} + +func (s *stubRuntime) ListSessionSkills(ctx context.Context, sessionID string) ([]agentruntime.SessionSkillState, error) { + return nil, nil +} + func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { return agentsession.NewWithWorkdir("draft", workdir), nil } From 1332e9badbb3300715e77933a9e6008d30f8612a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 02:45:31 +0000 Subject: [PATCH 2/3] fix(runtime): normalize skill events and dedupe missing notifications Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/app/bootstrap.go | 2 +- internal/runtime/skills.go | 18 ++++++++++++++--- internal/runtime/skills_test.go | 36 ++++++++++++++++++++++++++++++++- internal/runtime/state.go | 24 ++++++++++++++++++++-- 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 088d073c..f5cd22c4 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -262,7 +262,7 @@ func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error) return toolRegistry, mcpRegistry.Close, nil } -// buildSkillsRegistry 负责以最小代价初始化本地 skills registry,失败时返回 nil 并记录日志。 +// buildSkillsRegistry 负责以最小代价初始化本地 skills registry,refresh 失败时仅记录日志并保留 registry 实例。 func buildSkillsRegistry(ctx context.Context, baseDir string) skills.Registry { root := filepath.Join(baseDir, "skills") registry := skills.NewRegistry(skills.NewLocalLoader(root)) diff --git a/internal/runtime/skills.go b/internal/runtime/skills.go index f2298f19..6f5bcd4a 100644 --- a/internal/runtime/skills.go +++ b/internal/runtime/skills.go @@ -43,7 +43,7 @@ func (s *Service) ActivateSessionSkill(ctx context.Context, sessionID string, sk return err } if changed { - _ = s.emit(ctx, EventSkillActivated, "", session.ID, SessionSkillEventPayload{SkillID: descriptor.ID}) + _ = s.emit(ctx, EventSkillActivated, "", session.ID, SessionSkillEventPayload{SkillID: normalizeRuntimeSkillID(descriptor.ID)}) } return nil } @@ -128,7 +128,7 @@ func (s *Service) resolveActiveSkills(ctx context.Context, state *runState) ([]s } if s.skillsRegistry == nil { for _, skillID := range activeSkillIDs { - _ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID}) + s.emitSkillMissingOnce(ctx, state, skillID) } return nil, nil } @@ -138,7 +138,7 @@ func (s *Service) resolveActiveSkills(ctx context.Context, state *runState) ([]s descriptor, content, err := s.skillsRegistry.Get(ctx, skillID) if err != nil { if errors.Is(err, skills.ErrSkillNotFound) { - _ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID}) + s.emitSkillMissingOnce(ctx, state, skillID) continue } return nil, err @@ -151,6 +151,18 @@ func (s *Service) resolveActiveSkills(ctx context.Context, state *runState) ([]s return resolved, nil } +// emitSkillMissingOnce 在同一次 run 内只上报一次指定 skill 的缺失事件,避免重复噪音。 +func (s *Service) emitSkillMissingOnce(ctx context.Context, state *runState, skillID string) { + if state == nil { + _ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID}) + return + } + if !state.markSkillMissingReported(skillID) { + return + } + _ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID}) +} + // mutateSessionSkills 串行修改 session 的激活 skills,并在发生变化时立即持久化。 func (s *Service) mutateSessionSkills( ctx context.Context, diff --git a/internal/runtime/skills_test.go b/internal/runtime/skills_test.go index 61b58d0c..a7ed44c1 100644 --- a/internal/runtime/skills_test.go +++ b/internal/runtime/skills_test.go @@ -62,7 +62,7 @@ func TestActivateSessionSkillPersistsAndEmitsEvent(t *testing.T) { service.SetSkillsRegistry(&stubSkillsRegistry{ skills: map[string]skills.Skill{ "go-review": { - Descriptor: skills.Descriptor{ID: "go-review", Name: "Go Review"}, + Descriptor: skills.Descriptor{ID: "go_review", Name: "Go Review"}, Content: skills.Content{Instruction: "review code"}, }, }, @@ -183,6 +183,40 @@ func TestPrepareTurnSnapshotEmitsSkillMissingAndContinues(t *testing.T) { } } +func TestPrepareTurnSnapshotDeduplicatesSkillMissingPerRun(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-missing-skill-dedupe") + session.ActivateSkill("missing-skill") + store.sessions[session.ID] = cloneSession(session) + + builder := &stubContextBuilder{} + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, builder) + + state := newRunState("run-missing-skill-dedupe", session) + if _, rebuilt, err := service.prepareTurnSnapshot(context.Background(), &state); err != nil { + t.Fatalf("first prepareTurnSnapshot() error = %v", err) + } else if rebuilt { + t.Fatalf("did not expect first snapshot rebuild") + } + if _, rebuilt, err := service.prepareTurnSnapshot(context.Background(), &state); err != nil { + t.Fatalf("second prepareTurnSnapshot() error = %v", err) + } else if rebuilt { + t.Fatalf("did not expect second snapshot rebuild") + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 || events[0].Type != EventSkillMissing { + t.Fatalf("expected exactly one skill_missing event, got %+v", events) + } + payload, ok := events[0].Payload.(SessionSkillEventPayload) + if !ok || payload.SkillID != "missing-skill" { + t.Fatalf("unexpected event payload: %+v", events[0].Payload) + } +} + func TestPrepareTurnSnapshotPropagatesRegistryFailure(t *testing.T) { t.Parallel() diff --git a/internal/runtime/state.go b/internal/runtime/state.go index c0b160bc..a8ae3c67 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -26,13 +26,15 @@ type runState struct { phase controlplane.Phase stopEmitted bool progress controlplane.ProgressState + reportedMissingSkills map[string]struct{} } // newRunState 基于持久化会话创建一次运行的内存状态镜像。 func newRunState(runID string, session agentsession.Session) runState { return runState{ - runID: runID, - session: session, + runID: runID, + session: session, + reportedMissingSkills: make(map[string]struct{}), } } @@ -62,6 +64,24 @@ func (s *runState) touchSession() { s.session.UpdatedAt = time.Now() } +// markSkillMissingReported 记录并返回某个缺失 skill 是否首次在当前 run 中上报。 +func (s *runState) markSkillMissingReported(skillID string) bool { + if s == nil { + return true + } + normalized := normalizeRuntimeSkillID(skillID) + if normalized == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.reportedMissingSkills[normalized]; exists { + return false + } + s.reportedMissingSkills[normalized] = struct{}{} + return true +} + // turnSnapshot 冻结单轮推理所需的配置、上下文与 provider 请求。 type turnSnapshot struct { config config.Config From a291625139db6ba87cbda7686eaeaa163bc0a414 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 03:11:49 +0000 Subject: [PATCH 3/3] test: add coverage for session/runtime/context skill branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/app/bootstrap_test.go | 17 ++ internal/context/source_skills_test.go | 51 +++++ .../runtime/runtime_internal_helpers_test.go | 39 ++++ internal/runtime/skills_test.go | 178 ++++++++++++++++++ internal/session/skill_activation_test.go | 43 +++++ 5 files changed, 328 insertions(+) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index dd685c83..4e4876d5 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -826,6 +826,23 @@ func TestBuildRuntimeInjectsSkillsRegistryWhenRootExists(t *testing.T) { } } +func TestBuildSkillsRegistryKeepsInstanceWhenRefreshFails(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + registry := buildSkillsRegistry(canceledCtx, baseDir) + if registry == nil { + t.Fatalf("expected non-nil registry even when refresh fails") + } + _, _, err := registry.Get(context.Background(), "missing") + if !errors.Is(err, skills.ErrSkillNotFound) { + t.Fatalf("expected empty catalog behavior, got %v", err) + } +} + func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { disableBuiltinProviderAPIKeys(t) diff --git a/internal/context/source_skills_test.go b/internal/context/source_skills_test.go index f37da848..e5e83e9d 100644 --- a/internal/context/source_skills_test.go +++ b/internal/context/source_skills_test.go @@ -2,6 +2,7 @@ package context import ( stdcontext "context" + "errors" "strings" "testing" @@ -85,3 +86,53 @@ func TestDefaultBuilderBuildSkipsSkillsSectionWhenNoActiveSkills(t *testing.T) { t.Fatalf("did not expect skills section without active skills, got %q", result.SystemPrompt) } } + +func TestSkillPromptSourceSectionsHonorsContextCancel(t *testing.T) { + t.Parallel() + + canceledCtx, cancel := stdcontext.WithCancel(stdcontext.Background()) + cancel() + _, err := (skillPromptSource{}).Sections(canceledCtx, BuildInput{}) + if !errors.Is(err, stdcontext.Canceled) { + t.Fatalf("expected canceled error, got %v", err) + } +} + +func TestNormalizeActiveSkillsDropsBlankIDs(t *testing.T) { + t.Parallel() + + normalized := normalizeActiveSkills([]skills.Skill{ + {Descriptor: skills.Descriptor{ID: " "}}, + {Descriptor: skills.Descriptor{ID: "go_review", Name: "Go Review"}}, + }) + if len(normalized) != 1 || normalized[0].Descriptor.ID != "go_review" { + t.Fatalf("unexpected normalized skills: %+v", normalized) + } +} + +func TestTruncateSkillReferencesAndHelpers(t *testing.T) { + t.Parallel() + + references := truncateSkillReferences([]skills.Reference{ + {Title: "A", Summary: "sum-a"}, + {Title: "TitleOnly"}, + {Summary: "SummaryOnly"}, + {Path: "/tmp/path-only"}, + {Title: "A", Summary: "sum-a"}, + }, 4) + if len(references) != 4 { + t.Fatalf("expected four rendered references, got %+v", references) + } + if references[0] != "A: sum-a" || references[1] != "TitleOnly" || references[2] != "SummaryOnly" || references[3] != "/tmp/path-only" { + t.Fatalf("unexpected rendered references: %+v", references) + } + if got := truncateSkillReferences([]skills.Reference{{Title: "x"}}, 0); got != nil { + t.Fatalf("expected nil references when limit <= 0, got %+v", got) + } + if got := min(3, 1); got != 1 { + t.Fatalf("unexpected min result: %d", got) + } + if got := normalizeSkillID(" - "); got != "" { + t.Fatalf("expected blank normalized skill id, got %q", got) + } +} diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 97d779ad..91e86a91 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -90,6 +90,28 @@ func TestRunStateMutationsAndSync(t *testing.T) { } } +func TestRunStateMarkSkillMissingReportedBranches(t *testing.T) { + t.Parallel() + + session := newRuntimeSession("session-mark-missing") + state := newRunState("run-mark-missing", session) + + if !state.markSkillMissingReported("Go_Review") { + t.Fatalf("expected first mark to succeed") + } + if state.markSkillMissingReported("go-review") { + t.Fatalf("expected normalized duplicate to be rejected") + } + if state.markSkillMissingReported(" - ") { + t.Fatalf("expected blank normalized id to be rejected") + } + + var nilState *runState + if !nilState.markSkillMissingReported("anything") { + t.Fatalf("expected nil run state to allow reporting") + } +} + func TestAppendAssistantMessageAndSaveMetadataBranches(t *testing.T) { t.Parallel() @@ -188,6 +210,23 @@ func TestAppendToolMessageAndSaveUnlocksStateBeforePersist(t *testing.T) { } } +func TestAgentSessionCloneSkillActivationsCreatesDeepCopy(t *testing.T) { + t.Parallel() + + original := []agentsession.SkillActivation{{SkillID: "go-review"}} + cloned := agentsessionCloneSkillActivations(original) + if len(cloned) != 1 || cloned[0].SkillID != "go-review" { + t.Fatalf("unexpected cloned activations: %+v", cloned) + } + cloned[0].SkillID = "changed" + if original[0].SkillID != "go-review" { + t.Fatalf("expected source activation to remain unchanged, got %+v", original) + } + if agentsessionCloneSkillActivations(nil) != nil { + t.Fatalf("expected nil activation input to return nil") + } +} + func TestEmitTokenUsageSkipsZeroUsage(t *testing.T) { t.Parallel() diff --git a/internal/runtime/skills_test.go b/internal/runtime/skills_test.go index a7ed44c1..5fb72c9b 100644 --- a/internal/runtime/skills_test.go +++ b/internal/runtime/skills_test.go @@ -11,6 +11,7 @@ import ( agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" "neo-code/internal/skills" "neo-code/internal/tools" ) @@ -106,6 +107,26 @@ func TestActivateSessionSkillRejectsMissingSkill(t *testing.T) { } } +func TestActivateSessionSkillValidatesInputAndRegistry(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := service.ActivateSessionSkill(canceledCtx, "session-id", "go-review"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled context error, got %v", err) + } + if err := service.ActivateSessionSkill(context.Background(), " ", "go-review"); err == nil { + t.Fatalf("expected empty session id to fail") + } + if err := service.ActivateSessionSkill(context.Background(), "session-id", "go-review"); !errors.Is(err, errSkillsRegistryUnavailable) { + t.Fatalf("expected registry unavailable error, got %v", err) + } +} + func TestDeactivateSessionSkillIsIdempotentForUnknownSkill(t *testing.T) { t.Parallel() @@ -124,6 +145,47 @@ func TestDeactivateSessionSkillIsIdempotentForUnknownSkill(t *testing.T) { } } +func TestDeactivateSessionSkillEmitsEventWhenChanged(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newRuntimeSession("session-deactivate-emits") + session.ActivateSkill("go_review") + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + if err := service.DeactivateSessionSkill(context.Background(), session.ID, "GO_REVIEW"); err != nil { + t.Fatalf("DeactivateSessionSkill() error = %v", err) + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 || events[0].Type != EventSkillDeactivated { + t.Fatalf("expected skill_deactivated event, got %+v", events) + } + payload, ok := events[0].Payload.(SessionSkillEventPayload) + if !ok || payload.SkillID != "go-review" { + t.Fatalf("unexpected event payload: %+v", events[0].Payload) + } +} + +func TestDeactivateSessionSkillValidatesInput(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := service.DeactivateSessionSkill(canceledCtx, "session-id", "go-review"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled context error, got %v", err) + } + if err := service.DeactivateSessionSkill(context.Background(), " ", "go-review"); err == nil { + t.Fatalf("expected empty session id to fail") + } +} + func TestPrepareTurnSnapshotPassesResolvedSkillsToContextBuilder(t *testing.T) { t.Parallel() @@ -256,6 +318,122 @@ func TestListSessionSkillsPropagatesRegistryFailure(t *testing.T) { } } +func TestListSessionSkillsHandlesEmptyMissingAndResolvedStates(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + empty := newRuntimeSession("session-list-empty") + store.sessions[empty.ID] = cloneSession(empty) + states, err := service.ListSessionSkills(context.Background(), empty.ID) + if err != nil { + t.Fatalf("ListSessionSkills() error = %v", err) + } + if states != nil { + t.Fatalf("expected nil states for empty session, got %+v", states) + } + + missing := newRuntimeSession("session-list-missing") + missing.ActivateSkill("missing") + store.sessions[missing.ID] = cloneSession(missing) + states, err = service.ListSessionSkills(context.Background(), missing.ID) + if err != nil { + t.Fatalf("ListSessionSkills() error = %v", err) + } + if len(states) != 1 || !states[0].Missing || states[0].Descriptor != nil { + t.Fatalf("expected missing state when registry is nil, got %+v", states) + } + + resolved := newRuntimeSession("session-list-resolved") + resolved.ActivateSkill("go-review") + store.sessions[resolved.ID] = cloneSession(resolved) + service.SetSkillsRegistry(&stubSkillsRegistry{ + skills: map[string]skills.Skill{ + "go-review": { + Descriptor: skills.Descriptor{ID: "go-review", Name: "Go Review"}, + Content: skills.Content{Instruction: "review code"}, + }, + }, + }) + states, err = service.ListSessionSkills(context.Background(), resolved.ID) + if err != nil { + t.Fatalf("ListSessionSkills() error = %v", err) + } + if len(states) != 1 || states[0].Missing || states[0].Descriptor == nil || states[0].Descriptor.ID != "go-review" { + t.Fatalf("expected resolved descriptor state, got %+v", states) + } +} + +func TestListSessionSkillsValidatesInput(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := service.ListSessionSkills(canceledCtx, "session-id"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled context error, got %v", err) + } + if _, err := service.ListSessionSkills(context.Background(), " "); err == nil { + t.Fatalf("expected empty session id to fail") + } +} + +func TestMutateSessionSkillsCoversValidationAndSaveFailure(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + baseStore := newMemoryStore() + session := newRuntimeSession("session-mutate-branches") + baseStore.sessions[session.ID] = cloneSession(session) + service := NewWithFactory(manager, &stubToolManager{}, baseStore, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + if _, _, err := service.mutateSessionSkills(context.Background(), session.ID, nil); err == nil { + t.Fatalf("expected nil mutate function to fail") + } + + failing := &failingStore{Store: baseStore, saveErr: errors.New("save failed"), failOnSave: 1, ignoreContextErr: true} + service.sessionStore = failing + if _, _, err := service.mutateSessionSkills(context.Background(), session.ID, func(current *agentsession.Session) bool { + return current.ActivateSkill("go-review") + }); err == nil { + t.Fatalf("expected save failure to propagate") + } +} + +func TestEmitSkillMissingOnceHandlesNilStateAndDedup(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + service.emitSkillMissingOnce(context.Background(), nil, "missing-nil-state") + state := newRunState("run-missing-once", newRuntimeSession("session-missing-once")) + service.emitSkillMissingOnce(context.Background(), &state, "go_review") + service.emitSkillMissingOnce(context.Background(), &state, "go-review") + + events := collectRuntimeEvents(service.Events()) + if len(events) != 2 { + t.Fatalf("expected one nil-state event and one deduped run-state event, got %+v", events) + } +} + +func TestNormalizeRuntimeSkillID(t *testing.T) { + t.Parallel() + + if got := normalizeRuntimeSkillID(" Go_Review "); got != "go-review" { + t.Fatalf("unexpected normalized id: %q", got) + } + if got := normalizeRuntimeSkillID(" - "); got != "" { + t.Fatalf("expected blank normalized id, got %q", got) + } +} + func TestServiceRunReinjectsSkillsAfterAutoCompact(t *testing.T) { t.Parallel() diff --git a/internal/session/skill_activation_test.go b/internal/session/skill_activation_test.go index 034071e1..082fca71 100644 --- a/internal/session/skill_activation_test.go +++ b/internal/session/skill_activation_test.go @@ -122,3 +122,46 @@ func TestJSONStoreLoadAllowsMissingActivatedSkillsField(t *testing.T) { t.Fatalf("expected no activated skills, got %+v", loaded.ActiveSkillIDs()) } } + +func TestSkillActivationHelpersHandleNilSessionAndBlankInput(t *testing.T) { + t.Parallel() + + var nilSession *Session + if nilSession.ActivateSkill("go-review") { + t.Fatalf("expected nil session activate to be no-op") + } + if nilSession.DeactivateSkill("go-review") { + t.Fatalf("expected nil session deactivate to be no-op") + } + + session := New("blank") + if session.ActivateSkill(" ") { + t.Fatalf("expected blank skill id to be rejected") + } + if session.DeactivateSkill(" ") { + t.Fatalf("expected blank deactivation to be rejected") + } +} + +func TestSkillActivationCloneHelpers(t *testing.T) { + t.Parallel() + + original := []SkillActivation{{SkillID: "go-review"}, {SkillID: "zeta"}} + cloned := cloneSkillActivations(original) + if len(cloned) != len(original) { + t.Fatalf("expected clone length %d, got %d", len(original), len(cloned)) + } + + cloned[0].SkillID = "changed" + if original[0].SkillID != "go-review" { + t.Fatalf("expected source not to be mutated, got %+v", original) + } + + if got := (SkillActivation{SkillID: "go-review"}).Clone(); got.SkillID != "go-review" { + t.Fatalf("unexpected clone result: %+v", got) + } + + if cloneSkillActivations(nil) != nil { + t.Fatalf("expected nil input to cloneSkillActivations to return nil") + } +}