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 01/10] =?UTF-8?q?feat(skills):=20=E6=89=93=E9=80=9A=20sess?= =?UTF-8?q?ion/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 02/10] 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 03/10] 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") + } +} From 97da88f6209242d9a3b4643e14ca867ea74254de Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 15 Apr 2026 12:48:11 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(runtime):=E5=A2=9E=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E9=87=8D=E5=A4=8D=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E7=86=94=E6=96=AD=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 3 +- internal/config/runtime.go | 15 ++++- internal/runtime/controlplane/progress.go | 27 +++++++- .../runtime/controlplane/progress_test.go | 26 ++++++- internal/runtime/run.go | 67 ++++++++++++++++++- internal/runtime/run_lifecycle.go | 3 + internal/runtime/runtime_test.go | 2 +- 7 files changed, 130 insertions(+), 13 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ffd0067c..03ec8197 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1593,7 +1593,8 @@ func TestValidateSnapshotPropagatesCompactError(t *testing.T) { }, }, Runtime: RuntimeConfig{ - MaxNoProgressStreak: 3, + MaxNoProgressStreak: 3, + MaxRepeatCycleStreak: 3, }, Context: ContextConfig{ Compact: CompactConfig{ diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 86bd20ce..a71b6de6 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -5,18 +5,21 @@ import ( ) const ( - DefaultMaxNoProgressStreak = 3 + DefaultMaxNoProgressStreak = 3 + DefaultMaxRepeatCycleStreak = 3 ) // RuntimeConfig 定义 runtime 层的可调参数。 type RuntimeConfig struct { - MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"` + MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"` + MaxRepeatCycleStreak int `yaml:"max_repeat_cycle_streak,omitempty"` } // defaultRuntimeConfig 返回 runtime 配置的静态默认值。 func defaultRuntimeConfig() RuntimeConfig { return RuntimeConfig{ - MaxNoProgressStreak: DefaultMaxNoProgressStreak, + MaxNoProgressStreak: DefaultMaxNoProgressStreak, + MaxRepeatCycleStreak: DefaultMaxRepeatCycleStreak, } } @@ -33,6 +36,9 @@ func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) { if c.MaxNoProgressStreak <= 0 { c.MaxNoProgressStreak = defaults.MaxNoProgressStreak } + if c.MaxRepeatCycleStreak <= 0 { + c.MaxRepeatCycleStreak = defaults.MaxRepeatCycleStreak + } } // Validate 校验 runtime 配置是否满足最小约束。 @@ -40,5 +46,8 @@ func (c RuntimeConfig) Validate() error { if c.MaxNoProgressStreak <= 0 { return errors.New("max_no_progress_streak must be greater than 0") } + if c.MaxRepeatCycleStreak <= 0 { + return errors.New("max_repeat_cycle_streak must be greater than 0") + } return nil } diff --git a/internal/runtime/controlplane/progress.go b/internal/runtime/controlplane/progress.go index a1d26875..f6d35164 100644 --- a/internal/runtime/controlplane/progress.go +++ b/internal/runtime/controlplane/progress.go @@ -23,17 +23,38 @@ type ProgressScore struct { // ProgressState 汇总当前运行期 progress 控制面状态。 type ProgressState struct { - LastScore ProgressScore `json:"last_score"` + LastScore ProgressScore `json:"last_score"` + LastSignature string `json:"last_signature,omitempty"` } // ApplyProgressEvidence 根据证据更新分值与 streak。 -func ApplyProgressEvidence(state ProgressState, records []ProgressEvidenceRecord) ProgressState { +func ApplyProgressEvidence(state ProgressState, records []ProgressEvidenceRecord, currentSignature string) ProgressState { next := state.LastScore + isRepeated := false + + if len(records) > 0 { + if currentSignature != "" && currentSignature == state.LastSignature { + isRepeated = true + } + } + + nextSignature := currentSignature + if len(records) == 0 { next.NoProgressStreak++ + next.RepeatCycleStreak = 0 + nextSignature = "" // Clear signature on failure to only count consecutive successes + } else if isRepeated { + next.NoProgressStreak++ + next.RepeatCycleStreak++ } else { next.NoProgressStreak = 0 + next.RepeatCycleStreak = 0 next.ScoreDelta++ } - return ProgressState{LastScore: next} + + return ProgressState{ + LastScore: next, + LastSignature: nextSignature, + } } diff --git a/internal/runtime/controlplane/progress_test.go b/internal/runtime/controlplane/progress_test.go index cd25006e..c51a5a56 100644 --- a/internal/runtime/controlplane/progress_test.go +++ b/internal/runtime/controlplane/progress_test.go @@ -5,7 +5,7 @@ import "testing" func TestApplyProgressEvidenceNoEvidenceIncrementsNoProgress(t *testing.T) { t.Parallel() state := ProgressState{} - next := ApplyProgressEvidence(state, nil) + next := ApplyProgressEvidence(state, nil, "") if next.LastScore.NoProgressStreak != 1 { t.Fatalf("expected no_progress_streak 1, got %d", next.LastScore.NoProgressStreak) } @@ -18,7 +18,7 @@ func TestApplyProgressEvidenceOnlyNonDupResetsNoProgressStreak(t *testing.T) { } next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ {Kind: EvidenceNewInfoNonDup}, - }) + }, "sig1") if next.LastScore.NoProgressStreak != 0 { t.Fatalf("expected streak reset to 0, got %d", next.LastScore.NoProgressStreak) } @@ -35,8 +35,28 @@ func TestApplyProgressEvidenceMixedResetsNoProgress(t *testing.T) { next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ {Kind: EvidenceNewInfoNonDup}, {Kind: ProgressEvidenceKind("other_evidence")}, - }) + }, "sig1") if next.LastScore.NoProgressStreak != 0 { t.Fatalf("expected streak reset, got %d", next.LastScore.NoProgressStreak) } } + +func TestApplyProgressEvidenceRepeatCycle(t *testing.T) { + t.Parallel() + state := ProgressState{ + LastScore: ProgressScore{NoProgressStreak: 1, RepeatCycleStreak: 1}, + LastSignature: "sig1", + } + next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ + {Kind: EvidenceNewInfoNonDup}, + }, "sig1") + if next.LastScore.NoProgressStreak != 2 { + t.Fatalf("expected no_progress_streak 2, got %d", next.LastScore.NoProgressStreak) + } + if next.LastScore.RepeatCycleStreak != 2 { + t.Fatalf("expected repeat_cycle_streak 2, got %d", next.LastScore.RepeatCycleStreak) + } + if next.LastSignature != "sig1" { + t.Fatalf("expected signature sig1, got %s", next.LastSignature) + } +} diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 34c9a35a..2444d9c8 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -2,6 +2,9 @@ package runtime import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "strings" @@ -19,6 +22,35 @@ import ( ) const selfHealingReminder = "System Reminder: You have made multiple consecutive attempts without making substantial progress. Please stop your current repetitive or ineffective strategy. Carefully review the previous errors, change your approach, or ask the user directly for help." +const selfHealingRepeatReminder = "System Reminder: You are repeatedly calling the same tool with the exact same arguments. This is an infinite loop. Please change your parameters, try a different tool, or ask the user for help." + +// computeToolSignature 计算单轮执行的工具签名,用于循环检测。 +func computeToolSignature(calls []providertypes.ToolCall) string { + if len(calls) == 0 { + return "" + } + var sb strings.Builder + for _, call := range calls { + sb.WriteString(call.Name) + sb.WriteString(":") + + // 尝试将 JSON 参数进行规范化序列化,以消除空格、换行和字段顺序带来的哈希差异 + var parsed interface{} + if err := json.Unmarshal([]byte(call.Arguments), &parsed); err == nil { + if canonicalBytes, err := json.Marshal(parsed); err == nil { + sb.WriteString(string(canonicalBytes)) + } else { + sb.WriteString(call.Arguments) // 序列化失败,降级为原始字符串 + } + } else { + sb.WriteString(call.Arguments) // 解析失败,降级为原始字符串 + } + + sb.WriteString(";") + } + hash := sha256.Sum256([]byte(sb.String())) + return hex.EncodeToString(hash[:]) +} // Run 执行一次完整的 ReAct 闭环:保存用户输入、驱动模型、执行工具并发出事件。 // 已有会话会先加锁再加载/更新,确保同一会话并发 Run 不会出现状态覆盖; @@ -134,6 +166,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { var evidence []controlplane.ProgressEvidenceRecord toolCallCount := len(turnResult.assistant.ToolCalls) + currentSignature := computeToolSignature(turnResult.assistant.ToolCalls) + state.mu.Lock() if len(state.session.Messages) >= toolCallCount { for i := len(state.session.Messages) - toolCallCount; i < len(state.session.Messages); i++ { @@ -143,13 +177,25 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { } } } - state.progress = controlplane.ApplyProgressEvidence(state.progress, evidence) + + state.progress = controlplane.ApplyProgressEvidence(state.progress, evidence, currentSignature) streak := state.progress.LastScore.NoProgressStreak + repeatStreak := state.progress.LastScore.RepeatCycleStreak currentScore := state.progress.LastScore state.mu.Unlock() s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) + repeatLimit := snapshot.config.Runtime.MaxRepeatCycleStreak + if repeatLimit <= 0 { + repeatLimit = config.DefaultMaxRepeatCycleStreak + } + + if repeatStreak >= repeatLimit { + err = ErrRepeatCycleLimit + return err + } + limit := snapshot.noProgressStreakLimit if streak >= limit { err = ErrNoProgressStreakLimit @@ -212,12 +258,21 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur state.mu.Lock() streak := state.progress.LastScore.NoProgressStreak + repeatStreak := state.progress.LastScore.RepeatCycleStreak state.mu.Unlock() limit := resolveNoProgressStreakLimit(cfg.Runtime) + repeatLimit := resolveRepeatCycleStreakLimit(cfg.Runtime) systemPrompt := builtContext.SystemPrompt - if streak == limit-1 { + if repeatStreak == repeatLimit-1 { + trimmed := strings.TrimSpace(systemPrompt) + if trimmed == "" { + systemPrompt = selfHealingRepeatReminder + } else { + systemPrompt = trimmed + "\n\n" + selfHealingRepeatReminder + } + } else if streak == limit-1 { trimmed := strings.TrimSpace(systemPrompt) if trimmed == "" { systemPrompt = selfHealingReminder @@ -251,6 +306,14 @@ func resolveNoProgressStreakLimit(rc config.RuntimeConfig) int { return rc.MaxNoProgressStreak } +// resolveRepeatCycleStreakLimit 统一解析重复调用循环阈值。 +func resolveRepeatCycleStreakLimit(rc config.RuntimeConfig) int { + if rc.MaxRepeatCycleStreak <= 0 { + return config.DefaultMaxRepeatCycleStreak + } + return rc.MaxRepeatCycleStreak +} + // callProviderWithRetry 使用冻结后的 turnSnapshot 执行 provider 调用与必要重试。 func (s *Service) callProviderWithRetry( ctx context.Context, diff --git a/internal/runtime/run_lifecycle.go b/internal/runtime/run_lifecycle.go index 52a5015f..919b6ea6 100644 --- a/internal/runtime/run_lifecycle.go +++ b/internal/runtime/run_lifecycle.go @@ -19,6 +19,9 @@ var ErrMaxLoopReached = errors.New("runtime: max loop reached") // ErrNoProgressStreakLimit 表示循环内连续多次未取得进展,触发死循环拦截。 var ErrNoProgressStreakLimit = errors.New("runtime: no progress streak limit reached") +// ErrRepeatCycleLimit 表示连续多次重复调用相同的工具且参数相同,触发死循环拦截。 +var ErrRepeatCycleLimit = errors.New("runtime: repeat cycle limit reached") + // transitionRunPhase 在阶段变化时发出 phase_changed 并更新 runState。 func (s *Service) transitionRunPhase(ctx context.Context, state *runState, next controlplane.Phase) { if state == nil || state.phase == next { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 72837909..cdfa0540 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1685,7 +1685,7 @@ func TestServiceRunErrorPaths(t *testing.T) { { ID: fmt.Sprintf("loop-call-%d", i), Name: "filesystem_edit", - Arguments: `{"path":"x"}`, + Arguments: fmt.Sprintf(`{"path":"x", "iteration": %d}`, i), }, }, }, From f8bc410a7193c0dda6edadcbe17a4f9a75cec925 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 05:04:03 +0000 Subject: [PATCH 05/10] =?UTF-8?q?fix(runtime):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=BE=AA=E7=8E=AF=E9=85=8D=E7=BD=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=9B=9E=E5=BD=92=E5=B9=B6=E8=A1=A5=E9=BD=90=E7=86=94?= =?UTF-8?q?=E6=96=AD=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/config/runtime.go | 4 +- internal/config/runtime_test.go | 25 ++++++++-- internal/runtime/runtime_progress_test.go | 61 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/internal/config/runtime.go b/internal/config/runtime.go index a71b6de6..2f38ecee 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -46,8 +46,8 @@ func (c RuntimeConfig) Validate() error { if c.MaxNoProgressStreak <= 0 { return errors.New("max_no_progress_streak must be greater than 0") } - if c.MaxRepeatCycleStreak <= 0 { - return errors.New("max_repeat_cycle_streak must be greater than 0") + if c.MaxRepeatCycleStreak < 0 { + return errors.New("max_repeat_cycle_streak must be greater than or equal to 0") } return nil } diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index 0c6c705d..7063d457 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -5,29 +5,38 @@ import "testing" func TestRuntimeConfigClone(t *testing.T) { t.Parallel() - cfg := RuntimeConfig{MaxNoProgressStreak: 7} + cfg := RuntimeConfig{MaxNoProgressStreak: 7, MaxRepeatCycleStreak: 4} cloned := cfg.Clone() if cloned.MaxNoProgressStreak != 7 { t.Fatalf("expected cloned MaxNoProgressStreak=7, got %d", cloned.MaxNoProgressStreak) } + if cloned.MaxRepeatCycleStreak != 4 { + t.Fatalf("expected cloned MaxRepeatCycleStreak=4, got %d", cloned.MaxRepeatCycleStreak) + } } func TestRuntimeConfigApplyDefaults(t *testing.T) { t.Parallel() - defaults := RuntimeConfig{MaxNoProgressStreak: 3} + defaults := RuntimeConfig{MaxNoProgressStreak: 3, MaxRepeatCycleStreak: 5} - cfg := RuntimeConfig{MaxNoProgressStreak: 0} + cfg := RuntimeConfig{MaxNoProgressStreak: 0, MaxRepeatCycleStreak: 0} cfg.ApplyDefaults(defaults) if cfg.MaxNoProgressStreak != 3 { t.Fatalf("expected defaulted MaxNoProgressStreak=3, got %d", cfg.MaxNoProgressStreak) } + if cfg.MaxRepeatCycleStreak != 5 { + t.Fatalf("expected defaulted MaxRepeatCycleStreak=5, got %d", cfg.MaxRepeatCycleStreak) + } - cfg = RuntimeConfig{MaxNoProgressStreak: 5} + cfg = RuntimeConfig{MaxNoProgressStreak: 5, MaxRepeatCycleStreak: 8} cfg.ApplyDefaults(defaults) if cfg.MaxNoProgressStreak != 5 { t.Fatalf("expected existing MaxNoProgressStreak=5 to be preserved, got %d", cfg.MaxNoProgressStreak) } + if cfg.MaxRepeatCycleStreak != 8 { + t.Fatalf("expected existing MaxRepeatCycleStreak=8 to be preserved, got %d", cfg.MaxRepeatCycleStreak) + } var nilCfg *RuntimeConfig nilCfg.ApplyDefaults(defaults) @@ -36,7 +45,7 @@ func TestRuntimeConfigApplyDefaults(t *testing.T) { func TestRuntimeConfigValidate(t *testing.T) { t.Parallel() - if err := (RuntimeConfig{MaxNoProgressStreak: 1}).Validate(); err != nil { + if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: 0}).Validate(); err != nil { t.Fatalf("expected valid config, got %v", err) } @@ -45,4 +54,10 @@ func TestRuntimeConfigValidate(t *testing.T) { t.Fatalf("expected validation error for MaxNoProgressStreak=%d", bad) } } + + for _, bad := range []int{-1, -99} { + if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: bad}).Validate(); err == nil { + t.Fatalf("expected validation error for MaxRepeatCycleStreak=%d", bad) + } + } } diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index f9e90a1a..5de57afb 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -169,3 +169,64 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { } } } + +func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { + t.Setenv("TEST_KEY", "dummy") + + cfg := config.Config{ + Providers: []config.ProviderConfig{{Name: "test-repeat", Driver: "test", BaseURL: "http://localhost", Model: "test", APIKeyEnv: "TEST_KEY"}}, + SelectedProvider: "test-repeat", + Workdir: t.TempDir(), + Runtime: config.RuntimeConfig{ + MaxNoProgressStreak: 10, + MaxRepeatCycleStreak: 3, + }, + } + + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{ + {Name: "tool_repeat"}, + }, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + return tools.ToolResult{Name: input.Name, Content: "ok", IsError: false}, nil + }, + } + + var promptInjected bool + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + if strings.Contains(req.SystemPrompt, selfHealingRepeatReminder) { + promptInjected = true + } + events <- providertypes.NewToolCallStartStreamEvent(0, "call_repeat", "tool_repeat") + events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_repeat", `{"path":"x"}`) + events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) + return nil + }, + }, + } + + manager := config.NewManager(config.NewLoader(t.TempDir(), &cfg)) + service := NewWithFactory( + manager, + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + err := service.Run(context.Background(), UserInput{ + RunID: "run-repeat-streak", + Content: "trigger repeat loop", + }) + if err == nil { + t.Fatal("expected repeat cycle limit error, got nil") + } + if !errors.Is(err, ErrRepeatCycleLimit) { + t.Fatalf("expected ErrRepeatCycleLimit, got %v", err) + } + if !promptInjected { + t.Fatal("expected repeat self-healing prompt to be injected before repeat limit is reached") + } +} From 9744a2c90800e0ee1008b6b56d54d6bbe8a8e219 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 05:44:18 +0000 Subject: [PATCH 06/10] =?UTF-8?q?fix(config/runtime):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20MaxRepeatCycleStreak=20=E4=B8=89=E5=A4=84=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=B8=80=E8=87=B4=E6=80=A7=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Validate 中 MaxRepeatCycleStreak 判断由 < 0 回退为 <= 0,与 ApplyDefaults / resolveRepeatCycleStreakLimit / 内联兜底三处保持一致 - 在 TestRuntimeConfigApplyDefaults 补充负值回退测试(-1 被替换为默认值) - 在 TestRuntimeConfigValidate 将有效测试入参从 0 改为 1,并将 0 纳入 无效值覆盖列表,覆盖 <= 0 分支 - 在 TestRepeatCycleStreakStopsRunAndInjectsReminder 补充 EventStopReasonDecided 事件断言(StopReasonError + detail 字段), 与 TestProgressStreakStopsRun 保持对称覆盖 Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/config/runtime.go | 4 ++-- internal/config/runtime_test.go | 10 ++++++++-- internal/runtime/runtime_progress_test.go | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 2f38ecee..a71b6de6 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -46,8 +46,8 @@ func (c RuntimeConfig) Validate() error { if c.MaxNoProgressStreak <= 0 { return errors.New("max_no_progress_streak must be greater than 0") } - if c.MaxRepeatCycleStreak < 0 { - return errors.New("max_repeat_cycle_streak must be greater than or equal to 0") + if c.MaxRepeatCycleStreak <= 0 { + return errors.New("max_repeat_cycle_streak must be greater than 0") } return nil } diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index 7063d457..bd493427 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -38,6 +38,12 @@ func TestRuntimeConfigApplyDefaults(t *testing.T) { t.Fatalf("expected existing MaxRepeatCycleStreak=8 to be preserved, got %d", cfg.MaxRepeatCycleStreak) } + cfg = RuntimeConfig{MaxNoProgressStreak: 2, MaxRepeatCycleStreak: -1} + cfg.ApplyDefaults(defaults) + if cfg.MaxRepeatCycleStreak != 5 { + t.Fatalf("expected negative MaxRepeatCycleStreak=-1 to be replaced by default=5, got %d", cfg.MaxRepeatCycleStreak) + } + var nilCfg *RuntimeConfig nilCfg.ApplyDefaults(defaults) } @@ -45,7 +51,7 @@ func TestRuntimeConfigApplyDefaults(t *testing.T) { func TestRuntimeConfigValidate(t *testing.T) { t.Parallel() - if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: 0}).Validate(); err != nil { + if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: 1}).Validate(); err != nil { t.Fatalf("expected valid config, got %v", err) } @@ -55,7 +61,7 @@ func TestRuntimeConfigValidate(t *testing.T) { } } - for _, bad := range []int{-1, -99} { + for _, bad := range []int{0, -1, -99} { if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: bad}).Validate(); err == nil { t.Fatalf("expected validation error for MaxRepeatCycleStreak=%d", bad) } diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index 5de57afb..39486932 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -226,6 +226,22 @@ func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { if !errors.Is(err, ErrRepeatCycleLimit) { t.Fatalf("expected ErrRepeatCycleLimit, got %v", err) } + + events := collectRuntimeEvents(service.Events()) + + assertEventContains(t, events, EventStopReasonDecided) + for _, e := range events { + if e.Type == EventStopReasonDecided { + payload := e.Payload.(StopReasonDecidedPayload) + if payload.Reason != controlplane.StopReasonError { + t.Errorf("expected StopReasonError, got %s", payload.Reason) + } + if payload.Detail != ErrRepeatCycleLimit.Error() { + t.Errorf("expected detail to be %q, got %q", ErrRepeatCycleLimit.Error(), payload.Detail) + } + } + } + if !promptInjected { t.Fatal("expected repeat self-healing prompt to be injected before repeat limit is reached") } From dd4be728e99116a3962d9b7b63d266d2bd9946fe Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 07:30:16 +0000 Subject: [PATCH 07/10] =?UTF-8?q?test(runtime):=20=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=B0=83=E7=94=A8=E7=86=94=E6=96=AD=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/runtime/runtime_progress_test.go | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index 39486932..5481f6e6 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -8,6 +8,7 @@ import ( "testing" "neo-code/internal/config" + agentcontext "neo-code/internal/context" providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/controlplane" "neo-code/internal/tools" @@ -246,3 +247,116 @@ func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { t.Fatal("expected repeat self-healing prompt to be injected before repeat limit is reached") } } + +func TestComputeToolSignatureNormalizationAndFallback(t *testing.T) { + if got := computeToolSignature(nil); got != "" { + t.Fatalf("expected empty signature for nil tool calls, got %q", got) + } + + callsA := []providertypes.ToolCall{ + {Name: "filesystem_read_file", Arguments: "{\n \"path\": \"/tmp/a.txt\",\n \"opts\": {\"y\": [2,3], \"x\": 1}\n}"}, + {Name: "bash", Arguments: "{\"cmd\":\"pwd\"}"}, + } + callsB := []providertypes.ToolCall{ + {Name: "filesystem_read_file", Arguments: "{\"opts\":{\"x\":1,\"y\":[2,3]},\"path\":\"/tmp/a.txt\"}"}, + {Name: "bash", Arguments: "{ \"cmd\" : \"pwd\" }"}, + } + sigA := computeToolSignature(callsA) + sigB := computeToolSignature(callsB) + if sigA != sigB { + t.Fatalf("expected canonicalized signatures to match, got %q vs %q", sigA, sigB) + } + + invalidA := []providertypes.ToolCall{{Name: "bash", Arguments: "{\"cmd\":"}} + invalidB := []providertypes.ToolCall{{Name: "bash", Arguments: "{\"cmd\":\"ls\"}"}} + if computeToolSignature(invalidA) == computeToolSignature(invalidB) { + t.Fatal("expected invalid-json fallback signature to differ from valid-json signature") + } +} + +func TestPrepareTurnSnapshotInjectRepeatReminderWithEmptyPrompt(t *testing.T) { + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Runtime.MaxRepeatCycleStreak = 3 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + service := &Service{ + configManager: manager, + contextBuilder: &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{SystemPrompt: "", Messages: input.Messages}, nil + }, + }, + toolManager: &stubToolManager{}, + } + state := newRunState("run-repeat-reminder-empty", newRuntimeSession("session-repeat-reminder-empty")) + state.progress.LastScore.RepeatCycleStreak = 2 + + snapshot, rebuilt, err := service.prepareTurnSnapshot(context.Background(), &state) + if err != nil { + t.Fatalf("prepareTurnSnapshot() error = %v", err) + } + if rebuilt { + t.Fatal("expected rebuilt=false") + } + if snapshot.request.SystemPrompt != selfHealingRepeatReminder { + t.Fatalf("expected repeat reminder only, got %q", snapshot.request.SystemPrompt) + } +} + +func TestPrepareTurnSnapshotRepeatReminderTakesPriority(t *testing.T) { + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Runtime.MaxNoProgressStreak = 3 + cfg.Runtime.MaxRepeatCycleStreak = 3 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + service := &Service{ + configManager: manager, + contextBuilder: &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{SystemPrompt: "base prompt", Messages: input.Messages}, nil + }, + }, + toolManager: &stubToolManager{}, + } + state := newRunState("run-reminder-priority", newRuntimeSession("session-reminder-priority")) + state.progress.LastScore.NoProgressStreak = 2 + state.progress.LastScore.RepeatCycleStreak = 2 + + snapshot, rebuilt, err := service.prepareTurnSnapshot(context.Background(), &state) + if err != nil { + t.Fatalf("prepareTurnSnapshot() error = %v", err) + } + if rebuilt { + t.Fatal("expected rebuilt=false") + } + if !strings.Contains(snapshot.request.SystemPrompt, selfHealingRepeatReminder) { + t.Fatalf("expected prompt to contain repeat reminder, got %q", snapshot.request.SystemPrompt) + } + if strings.Contains(snapshot.request.SystemPrompt, selfHealingReminder) { + t.Fatalf("expected no-progress reminder to be skipped when repeat reminder is injected, got %q", snapshot.request.SystemPrompt) + } +} + +func TestResolveStreakLimitDefaults(t *testing.T) { + if got := resolveNoProgressStreakLimit(config.RuntimeConfig{MaxNoProgressStreak: 0}); got != config.DefaultMaxNoProgressStreak { + t.Fatalf("expected default no-progress limit %d, got %d", config.DefaultMaxNoProgressStreak, got) + } + if got := resolveNoProgressStreakLimit(config.RuntimeConfig{MaxNoProgressStreak: 8}); got != 8 { + t.Fatalf("expected explicit no-progress limit 8, got %d", got) + } + + if got := resolveRepeatCycleStreakLimit(config.RuntimeConfig{MaxRepeatCycleStreak: -1}); got != config.DefaultMaxRepeatCycleStreak { + t.Fatalf("expected default repeat limit %d, got %d", config.DefaultMaxRepeatCycleStreak, got) + } + if got := resolveRepeatCycleStreakLimit(config.RuntimeConfig{MaxRepeatCycleStreak: 6}); got != 6 { + t.Fatalf("expected explicit repeat limit 6, got %d", got) + } +} From b073953a210e827426aec25f50cc00c295863210 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 15 Apr 2026 16:27:38 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(context):=20=E4=BF=AE=E5=A4=8D=20meta?= =?UTF-8?q?data-only=20=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder_test.go | 42 ++++++++++ internal/context/projection.go | 5 +- internal/context/projection_test.go | 39 +++++++-- internal/memo/llm_extractor_test.go | 42 ++++++++++ .../runtime/runtime_internal_helpers_test.go | 83 +++++++++++++++++++ internal/runtime/runtime_test.go | 82 +++++++++++++++--- internal/runtime/session_mutation.go | 30 +++++-- internal/session/store_test.go | 50 +++++++++++ 8 files changed, 349 insertions(+), 24 deletions(-) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index efe32e55..b17921e7 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -777,3 +777,45 @@ func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T) t.Fatal("expected source messages to remain unchanged") } } + +func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + result, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "inspect README"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: `{"path":"README.md"}`}, + }, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: " ", + ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, + }, + }, + Metadata: testMetadata(t.TempDir()), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(result.Messages) != 3 { + t.Fatalf("len(result.Messages) = %d, want 3", len(result.Messages)) + } + toolMessage := result.Messages[2] + if toolMessage.Role != providertypes.RoleTool { + t.Fatalf("expected tool message at index 2, got %+v", toolMessage) + } + if !strings.Contains(toolMessage.Content, "tool result") || + !strings.Contains(toolMessage.Content, "tool: filesystem_read_file") || + !strings.Contains(toolMessage.Content, "meta.path: README.md") { + t.Fatalf("expected metadata-only tool result to be projected, got %q", toolMessage.Content) + } + if toolMessage.ToolMetadata != nil { + t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata) + } +} diff --git a/internal/context/projection.go b/internal/context/projection.go index 86c5a6dd..85e7ecd0 100644 --- a/internal/context/projection.go +++ b/internal/context/projection.go @@ -145,7 +145,10 @@ func isInjectableToolMessage(message providertypes.Message) bool { return false } content := strings.TrimSpace(message.Content) - return content != "" && content != microCompactClearedMessage + if content == microCompactClearedMessage { + return false + } + return content != "" || len(message.ToolMetadata) > 0 } // recentWindowMessageBudget 计算 recent window 可保留的消息总数硬上限,避免窗口体积失控。 diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go index 84116afa..5b8f428a 100644 --- a/internal/context/projection_test.go +++ b/internal/context/projection_test.go @@ -43,10 +43,10 @@ func TestProjectToolMessagesForModelSkipsMessagesThatCannotBeProjected(t *testin t.Fatalf("non-tool message should remain unchanged, got %+v", projected[0]) } if projected[1].Content != "tool output" || projected[1].ToolMetadata != nil { - t.Fatalf("tool without metadata-free projection should remain unchanged, got %+v", projected[1]) + t.Fatalf("tool without projection metadata should remain unchanged, got %+v", projected[1]) } - if projected[2].Content != " " || projected[2].ToolMetadata == nil { - t.Fatalf("empty tool content should not be projected, got %+v", projected[2]) + if !strings.Contains(projected[2].Content, "tool result") || projected[2].ToolMetadata != nil { + t.Fatalf("metadata-only tool message should be projected, got %+v", projected[2]) } if projected[3].Content != microCompactClearedMessage || projected[3].ToolMetadata == nil { t.Fatalf("cleared tool content should not be projected, got %+v", projected[3]) @@ -221,7 +221,7 @@ func TestMatchedToolCallSpanRejectsInvalidAssistantStates(t *testing.T) { } } -func TestMatchedToolCallSpanRequiresInjectableResponsesAndSkipsDuplicates(t *testing.T) { +func TestMatchedToolCallSpanRequiresProjectableResponsesAndSkipsDuplicates(t *testing.T) { t.Parallel() messages := []providertypes.Message{ @@ -244,11 +244,35 @@ func TestMatchedToolCallSpanRequiresInjectableResponsesAndSkipsDuplicates(t *tes if len(span) != 3 { t.Fatalf("len(span) = %d, want 3 (%+v)", len(span), span) } - if span[0] != 0 || span[1] != 2 || span[2] != 5 { + if span[0] != 0 || span[1] != 1 || span[2] != 5 { t.Fatalf("unexpected span indexes %+v", span) } } +func TestMatchedToolCallSpanAcceptsMetadataOnlyResponses(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "webfetch", Arguments: `{}`}, + }, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: " ", + ToolMetadata: map[string]string{"tool_name": "webfetch", "status_code": "200"}, + }, + } + + span := matchedToolCallSpan(messages, 0) + if len(span) != 2 || span[0] != 0 || span[1] != 1 { + t.Fatalf("unexpected metadata-only span %+v", span) + } +} + func TestMatchedToolCallSpanRejectsResponsesWithoutProjectionMetadata(t *testing.T) { t.Parallel() @@ -285,6 +309,11 @@ func TestIsInjectableToolMessage(t *testing.T) { message: providertypes.Message{Role: providertypes.RoleTool, Content: " "}, want: false, }, + { + name: "metadata-only", + message: providertypes.Message{Role: providertypes.RoleTool, Content: " ", ToolMetadata: map[string]string{"tool_name": "bash"}}, + want: true, + }, { name: "cleared", message: providertypes.Message{Role: providertypes.RoleTool, Content: microCompactClearedMessage}, diff --git a/internal/memo/llm_extractor_test.go b/internal/memo/llm_extractor_test.go index 7d1befea..10bc7864 100644 --- a/internal/memo/llm_extractor_test.go +++ b/internal/memo/llm_extractor_test.go @@ -299,6 +299,48 @@ func TestLLMExtractorExtractKeepsProjectedToolCallSpan(t *testing.T) { } } +func TestLLMExtractorExtractKeepsMetadataOnlyToolCallSpan(t *testing.T) { + generator := &stubTextGenerator{response: `[]`} + extractor := NewLLMExtractor(generator) + + _, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "remember this"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "filesystem_read_file", Arguments: `{"path":"README.md"}`}, + }, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call_1", + Content: "", + ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, + }, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(generator.messages) != 3 { + t.Fatalf("len(generator.messages) = %d, want 3", len(generator.messages)) + } + toolMessage := generator.messages[2] + if toolMessage.Role != providertypes.RoleTool { + t.Fatalf("expected projected tool message, got %#v", toolMessage) + } + if !strings.Contains(toolMessage.Content, "tool result") || + !strings.Contains(toolMessage.Content, "tool: filesystem_read_file") || + !strings.Contains(toolMessage.Content, "meta.path: README.md") { + t.Fatalf("expected metadata-only tool text, got %q", toolMessage.Content) + } + if strings.Contains(toolMessage.Content, "content:\n") { + t.Fatalf("expected metadata-only projection to omit content section, got %q", toolMessage.Content) + } + if toolMessage.ToolMetadata != nil { + t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata) + } +} + func TestLLMExtractorExtractSkipsOrphanAndClearedToolMessages(t *testing.T) { generator := &stubTextGenerator{response: `[]`} extractor := NewLLMExtractor(generator) diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 91e86a91..152b48e2 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -177,6 +177,89 @@ func TestAppendToolMessageAndSaveSanitizesMetadata(t *testing.T) { } } +func TestAppendToolMessageAndSavePreservesMetadataOnlySuccessResult(t *testing.T) { + t.Parallel() + + store := newMemoryStore() + session := newRuntimeSession("session-append-tool-metadata-only") + store.sessions[session.ID] = cloneSession(session) + + service := &Service{sessionStore: store} + state := newRunState("run-append-tool-metadata-only", session) + call := providertypes.ToolCall{ID: "call-1", Name: "filesystem_read_file"} + result := tools.ToolResult{ + Name: "filesystem_read_file", + Content: "", + Metadata: map[string]any{ + "path": "README.md", + }, + } + + if err := service.appendToolMessageAndSave(context.Background(), &state, call, result); err != nil { + t.Fatalf("appendToolMessageAndSave() error = %v", err) + } + + msg := state.session.Messages[0] + if msg.Content != "" { + t.Fatalf("expected metadata-only success result to keep empty content, got %q", msg.Content) + } + if msg.ToolMetadata["tool_name"] != "filesystem_read_file" || msg.ToolMetadata["path"] != "README.md" { + t.Fatalf("expected metadata-only success result to keep sanitized metadata, got %+v", msg.ToolMetadata) + } +} + +func TestAppendToolMessageAndSaveNormalizesSemanticallyEmptySuccessResult(t *testing.T) { + t.Parallel() + + store := newMemoryStore() + session := newRuntimeSession("session-append-tool-empty-success") + store.sessions[session.ID] = cloneSession(session) + + service := &Service{sessionStore: store} + state := newRunState("run-append-tool-empty-success", session) + call := providertypes.ToolCall{ID: "call-1", Name: "filesystem_read_file"} + result := tools.ToolResult{ + Name: "filesystem_read_file", + Content: " ", + } + + if err := service.appendToolMessageAndSave(context.Background(), &state, call, result); err != nil { + t.Fatalf("appendToolMessageAndSave() error = %v", err) + } + + msg := state.session.Messages[0] + if msg.Content != "ok" { + t.Fatalf("expected empty success result to be normalized to ok, got %q", msg.Content) + } + if msg.ToolMetadata["tool_name"] != "filesystem_read_file" { + t.Fatalf("expected tool_name metadata to be preserved after normalization, got %+v", msg.ToolMetadata) + } +} + +func TestAppendToolMessageAndSaveFallsBackToCallNameForToolMetadata(t *testing.T) { + t.Parallel() + + store := newMemoryStore() + session := newRuntimeSession("session-append-tool-name-fallback") + store.sessions[session.ID] = cloneSession(session) + + service := &Service{sessionStore: store} + state := newRunState("run-append-tool-name-fallback", session) + call := providertypes.ToolCall{ID: "call-1", Name: "filesystem_read_file"} + result := tools.ToolResult{ + Content: "ok", + } + + if err := service.appendToolMessageAndSave(context.Background(), &state, call, result); err != nil { + t.Fatalf("appendToolMessageAndSave() error = %v", err) + } + + msg := state.session.Messages[0] + if msg.ToolMetadata["tool_name"] != "filesystem_read_file" { + t.Fatalf("expected tool_name fallback from call name, got %+v", msg.ToolMetadata) + } +} + func TestAppendToolMessageAndSaveUnlocksStateBeforePersist(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 7de08a4f..43c43114 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -545,6 +545,76 @@ func TestServiceRun(t *testing.T) { } }, }, + { + name: "metadata-only tool result is projected on follow-up provider round", + input: UserInput{RunID: "run-tool-metadata-only", Content: "inspect file"}, + providerStreams: [][]providertypes.StreamEvent{ + { + providertypes.NewToolCallStartStreamEvent(0, "call-1", "filesystem_read_file"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-1", `{"path":"README.md"}`), + }, + { + providertypes.NewTextDeltaStreamEvent("done"), + }, + }, + registerTool: &stubTool{ + name: "filesystem_read_file", + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + return tools.ToolResult{ + Name: "filesystem_read_file", + Content: "", + Metadata: map[string]any{ + "path": "README.md", + }, + }, nil + }, + }, + contextBuilder: &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "stub system prompt", + Messages: projectToolMessagesForProviderTest(input.Messages), + }, nil + }, + }, + expectProviderCalls: 2, + expectToolCalls: 1, + expectMessageRoles: []string{"user", "assistant", "tool", "assistant"}, + expectEventTypes: []EventType{EventUserMessage, EventToolStart, EventToolResult, EventAgentDone}, + assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { + t.Helper() + if len(scripted.requests) != 2 { + t.Fatalf("expected 2 provider requests, got %d", len(scripted.requests)) + } + second := scripted.requests[1] + foundToolResult := false + for _, message := range second.Messages { + if message.Role == providertypes.RoleTool && + message.ToolCallID == "call-1" && + strings.Contains(message.Content, "tool result") && + strings.Contains(message.Content, "tool: filesystem_read_file") && + strings.Contains(message.Content, "meta.path: README.md") { + foundToolResult = true + if strings.Contains(message.Content, "content:\n") { + t.Fatalf("expected metadata-only projection to omit content section, got %q", message.Content) + } + break + } + } + if !foundToolResult { + t.Fatalf("expected projected metadata-only tool result in second provider request: %+v", second.Messages) + } + + session := onlySession(t, store) + if session.Messages[2].Role != providertypes.RoleTool || session.Messages[2].Content != "" { + t.Fatalf("expected persisted tool message to keep empty raw content, got %+v", session.Messages[2]) + } + if session.Messages[2].ToolMetadata["tool_name"] != "filesystem_read_file" || + session.Messages[2].ToolMetadata["path"] != "README.md" { + t.Fatalf("expected persisted metadata-only tool message to keep sanitized metadata, got %+v", session.Messages[2].ToolMetadata) + } + }, + }, } for _, tt := range tests { @@ -3031,17 +3101,7 @@ func cloneBuildInput(input agentcontext.BuildInput) agentcontext.BuildInput { // projectToolMessagesForProviderTest 模拟 context 层在 provider 请求前对 tool 消息做的只读投影。 func projectToolMessagesForProviderTest(messages []providertypes.Message) []providertypes.Message { - projected := append([]providertypes.Message(nil), messages...) - for i, message := range projected { - if message.Role != providertypes.RoleTool || len(message.ToolMetadata) == 0 { - continue - } - next := message - next.Content = tools.FormatToolMessageForModel(message) - next.ToolMetadata = nil - projected[i] = next - } - return projected + return agentcontext.ProjectToolMessagesForModel(cloneMessages(messages)) } func containsError(err error, target string) bool { diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index bf914917..38276cdd 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -55,13 +55,7 @@ func (s *Service) appendToolMessageAndSave( result tools.ToolResult, ) error { state.mu.Lock() - toolMessage := providertypes.Message{ - Role: providertypes.RoleTool, - Content: result.Content, - ToolCallID: call.ID, - IsError: result.IsError, - ToolMetadata: tools.SanitizeToolMetadata(result.Name, result.Metadata), - } + toolMessage := normalizeToolMessageForPersistence(call, result) state.session.Messages = append(state.session.Messages, toolMessage) state.touchSession() sessionSnapshot := cloneSessionForPersistence(state.session) @@ -69,6 +63,28 @@ func (s *Service) appendToolMessageAndSave( return s.sessionStore.Save(ctx, &sessionSnapshot) } +// normalizeToolMessageForPersistence 负责在写入会话前收敛工具结果,避免成功结果落成完全空语义消息。 +func normalizeToolMessageForPersistence(call providertypes.ToolCall, result tools.ToolResult) providertypes.Message { + toolName := strings.TrimSpace(result.Name) + if toolName == "" { + toolName = strings.TrimSpace(call.Name) + } + + sanitizedMetadata := tools.SanitizeToolMetadata(toolName, result.Metadata) + content := result.Content + if !result.IsError && strings.TrimSpace(content) == "" && len(tools.SanitizeToolMetadata("", result.Metadata)) == 0 { + content = "ok" + } + + return providertypes.Message{ + Role: providertypes.RoleTool, + Content: content, + ToolCallID: call.ID, + IsError: result.IsError, + ToolMetadata: sanitizedMetadata, + } +} + // cloneSessionForPersistence 复制会话快照,避免持久化阶段与并发写入共享可变切片/映射。 func cloneSessionForPersistence(session agentsession.Session) agentsession.Session { cloned := session diff --git a/internal/session/store_test.go b/internal/session/store_test.go index e08710b1..ec1c9834 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -643,6 +643,56 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { } } +func TestJSONStoreSaveRoundTripsMetadataOnlyToolMessage(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + session := &Session{ + SchemaVersion: CurrentSchemaVersion, + ID: "metadata-only-tool-message", + Title: "Metadata Only Tool Message", + CreatedAt: time.Now().Add(-time.Hour), + UpdatedAt: time.Now(), + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "inspect"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: `{"path":"README.md"}`}, + }, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: "", + ToolMetadata: map[string]string{ + "tool_name": "filesystem_read_file", + "path": "README.md", + }, + }, + }, + } + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save session: %v", err) + } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load saved session: %v", err) + } + if loaded.Messages[2].Content != "" { + t.Fatalf("expected empty content to round-trip, got %q", loaded.Messages[2].Content) + } + if loaded.Messages[2].ToolMetadata["tool_name"] != "filesystem_read_file" || + loaded.Messages[2].ToolMetadata["path"] != "README.md" { + t.Fatalf("expected metadata-only tool message round-trip, got %+v", loaded.Messages[2].ToolMetadata) + } +} + func TestDecodeStoredSummaryUsesLightweightMetadataPath(t *testing.T) { t.Parallel() From ad2055502c84097697e7f1e62d6f8c714b973187 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 08:36:32 +0000 Subject: [PATCH 09/10] fix(runtime): avoid duplicate metadata sanitize in tool message normalization Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- .../runtime/runtime_internal_helpers_test.go | 31 +++++++++++++++++++ internal/runtime/session_mutation.go | 14 ++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 152b48e2..46ac92f4 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -236,6 +236,37 @@ func TestAppendToolMessageAndSaveNormalizesSemanticallyEmptySuccessResult(t *tes } } +func TestAppendToolMessageAndSaveNormalizesToolNameOnlyMetadataSuccessResult(t *testing.T) { + t.Parallel() + + store := newMemoryStore() + session := newRuntimeSession("session-append-tool-name-only-metadata-success") + store.sessions[session.ID] = cloneSession(session) + + service := &Service{sessionStore: store} + state := newRunState("run-append-tool-name-only-metadata-success", session) + call := providertypes.ToolCall{ID: "call-1", Name: "filesystem_read_file"} + result := tools.ToolResult{ + Name: "filesystem_read_file", + Content: " ", + Metadata: map[string]any{ + "unsupported_key": "ignored", + }, + } + + if err := service.appendToolMessageAndSave(context.Background(), &state, call, result); err != nil { + t.Fatalf("appendToolMessageAndSave() error = %v", err) + } + + msg := state.session.Messages[0] + if msg.Content != "ok" { + t.Fatalf("expected tool_name-only metadata success to normalize content to ok, got %q", msg.Content) + } + if len(msg.ToolMetadata) != 1 || msg.ToolMetadata["tool_name"] != "filesystem_read_file" { + t.Fatalf("expected only tool_name metadata to remain, got %+v", msg.ToolMetadata) + } +} + func TestAppendToolMessageAndSaveFallsBackToCallNameForToolMetadata(t *testing.T) { t.Parallel() diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index 38276cdd..81c5844b 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -9,6 +9,8 @@ import ( "neo-code/internal/tools" ) +const toolNameMetadataKey = "tool_name" + // appendUserMessageAndSave 将用户消息追加到会话并立即持久化。 func (s *Service) appendUserMessageAndSave(ctx context.Context, state *runState, content string) error { message := providertypes.Message{ @@ -72,7 +74,7 @@ func normalizeToolMessageForPersistence(call providertypes.ToolCall, result tool sanitizedMetadata := tools.SanitizeToolMetadata(toolName, result.Metadata) content := result.Content - if !result.IsError && strings.TrimSpace(content) == "" && len(tools.SanitizeToolMetadata("", result.Metadata)) == 0 { + if !result.IsError && strings.TrimSpace(content) == "" && !hasNonToolNameToolMetadata(sanitizedMetadata) { content = "ok" } @@ -85,6 +87,16 @@ func normalizeToolMessageForPersistence(call providertypes.ToolCall, result tool } } +// hasNonToolNameToolMetadata 判断 metadata 中是否存在除 tool_name 外的语义字段。 +func hasNonToolNameToolMetadata(metadata map[string]string) bool { + for key := range metadata { + if key != toolNameMetadataKey { + return true + } + } + return false +} + // cloneSessionForPersistence 复制会话快照,避免持久化阶段与并发写入共享可变切片/映射。 func cloneSessionForPersistence(session agentsession.Session) agentsession.Session { cloned := session From afce7e3b43f410661b4989fda4e5beb8c5d4575e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 14:38:35 +0000 Subject: [PATCH 10/10] fix(runtime): align repeat-cycle breaker semantics and tests Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/runtime/controlplane/progress.go | 32 ++-- .../runtime/controlplane/progress_test.go | 71 ++++++--- internal/runtime/runtime_progress_test.go | 141 +++++++++++++----- 3 files changed, 174 insertions(+), 70 deletions(-) diff --git a/internal/runtime/controlplane/progress.go b/internal/runtime/controlplane/progress.go index f6d35164..784496ce 100644 --- a/internal/runtime/controlplane/progress.go +++ b/internal/runtime/controlplane/progress.go @@ -30,27 +30,29 @@ type ProgressState struct { // ApplyProgressEvidence 根据证据更新分值与 streak。 func ApplyProgressEvidence(state ProgressState, records []ProgressEvidenceRecord, currentSignature string) ProgressState { next := state.LastScore - isRepeated := false - - if len(records) > 0 { - if currentSignature != "" && currentSignature == state.LastSignature { - isRepeated = true + hasToolAttempt := currentSignature != "" + isRepeated := hasToolAttempt && state.LastSignature != "" && currentSignature == state.LastSignature + + if hasToolAttempt { + if isRepeated { + next.RepeatCycleStreak++ + } else { + next.RepeatCycleStreak = 1 } + } else { + next.RepeatCycleStreak = 0 } - nextSignature := currentSignature + nextSignature := "" + if hasToolAttempt { + nextSignature = currentSignature + } - if len(records) == 0 { - next.NoProgressStreak++ - next.RepeatCycleStreak = 0 - nextSignature = "" // Clear signature on failure to only count consecutive successes - } else if isRepeated { - next.NoProgressStreak++ - next.RepeatCycleStreak++ - } else { + if len(records) > 0 && !isRepeated { next.NoProgressStreak = 0 - next.RepeatCycleStreak = 0 next.ScoreDelta++ + } else { + next.NoProgressStreak++ } return ProgressState{ diff --git a/internal/runtime/controlplane/progress_test.go b/internal/runtime/controlplane/progress_test.go index c51a5a56..f457a0be 100644 --- a/internal/runtime/controlplane/progress_test.go +++ b/internal/runtime/controlplane/progress_test.go @@ -4,10 +4,15 @@ import "testing" func TestApplyProgressEvidenceNoEvidenceIncrementsNoProgress(t *testing.T) { t.Parallel() - state := ProgressState{} - next := ApplyProgressEvidence(state, nil, "") - if next.LastScore.NoProgressStreak != 1 { - t.Fatalf("expected no_progress_streak 1, got %d", next.LastScore.NoProgressStreak) + got := ApplyProgressEvidence(ProgressState{}, nil, "") + want := ProgressState{ + LastScore: ProgressScore{ + NoProgressStreak: 1, + RepeatCycleStreak: 0, + }, + } + if got != want { + t.Fatalf("expected %+v, got %+v", want, got) } } @@ -16,14 +21,19 @@ func TestApplyProgressEvidenceOnlyNonDupResetsNoProgressStreak(t *testing.T) { state := ProgressState{ LastScore: ProgressScore{NoProgressStreak: 3}, } - next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ + got := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ {Kind: EvidenceNewInfoNonDup}, }, "sig1") - if next.LastScore.NoProgressStreak != 0 { - t.Fatalf("expected streak reset to 0, got %d", next.LastScore.NoProgressStreak) + want := ProgressState{ + LastScore: ProgressScore{ + ScoreDelta: 1, + NoProgressStreak: 0, + RepeatCycleStreak: 1, + }, + LastSignature: "sig1", } - if next.LastScore.ScoreDelta != 1 { - t.Fatalf("expected score_delta 1, got %d", next.LastScore.ScoreDelta) + if got != want { + t.Fatalf("expected %+v, got %+v", want, got) } } @@ -32,31 +42,52 @@ func TestApplyProgressEvidenceMixedResetsNoProgress(t *testing.T) { state := ProgressState{ LastScore: ProgressScore{NoProgressStreak: 2}, } - next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ + got := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ {Kind: EvidenceNewInfoNonDup}, {Kind: ProgressEvidenceKind("other_evidence")}, }, "sig1") - if next.LastScore.NoProgressStreak != 0 { - t.Fatalf("expected streak reset, got %d", next.LastScore.NoProgressStreak) + if got.LastScore.NoProgressStreak != 0 { + t.Fatalf("expected streak reset, got %d", got.LastScore.NoProgressStreak) } } func TestApplyProgressEvidenceRepeatCycle(t *testing.T) { t.Parallel() state := ProgressState{ - LastScore: ProgressScore{NoProgressStreak: 1, RepeatCycleStreak: 1}, + LastScore: ProgressScore{NoProgressStreak: 1, RepeatCycleStreak: 2}, LastSignature: "sig1", } - next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ + got := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ {Kind: EvidenceNewInfoNonDup}, }, "sig1") - if next.LastScore.NoProgressStreak != 2 { - t.Fatalf("expected no_progress_streak 2, got %d", next.LastScore.NoProgressStreak) + want := ProgressState{ + LastScore: ProgressScore{ + NoProgressStreak: 2, + RepeatCycleStreak: 3, + }, + LastSignature: "sig1", + } + if got != want { + t.Fatalf("expected %+v, got %+v", want, got) + } +} + +func TestApplyProgressEvidenceRepeatCycleOnFailureKeepsSignatureTracking(t *testing.T) { + t.Parallel() + state := ProgressState{ + LastScore: ProgressScore{NoProgressStreak: 2, RepeatCycleStreak: 1}, + LastSignature: "sig1", } - if next.LastScore.RepeatCycleStreak != 2 { - t.Fatalf("expected repeat_cycle_streak 2, got %d", next.LastScore.RepeatCycleStreak) + + got := ApplyProgressEvidence(state, nil, "sig1") + want := ProgressState{ + LastScore: ProgressScore{ + NoProgressStreak: 3, + RepeatCycleStreak: 2, + }, + LastSignature: "sig1", } - if next.LastSignature != "sig1" { - t.Fatalf("expected signature sig1, got %s", next.LastSignature) + if got != want { + t.Fatalf("expected %+v, got %+v", want, got) } } diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index 5481f6e6..89ec5189 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -3,6 +3,7 @@ package runtime import ( "context" "errors" + "strconv" "strings" "sync/atomic" "testing" @@ -34,15 +35,21 @@ func TestProgressStreakStopsRun(t *testing.T) { } var promptInjected bool + var signatureSeq int32 providerFactory := &scriptedProviderFactory{ provider: &scriptedProvider{ chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + seq := atomic.AddInt32(&signatureSeq, 1) if strings.Contains(req.SystemPrompt, selfHealingReminder) { promptInjected = true } // the model always decides to call the tool events <- providertypes.NewToolCallStartStreamEvent(0, "call_err", "tool_error") - events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_err", "{}") + events <- providertypes.NewToolCallDeltaStreamEvent( + 0, + "call_err", + `{"seq":`+strconv.FormatInt(int64(seq), 10)+`}`, + ) events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) return nil }, @@ -76,19 +83,7 @@ func TestProgressStreakStopsRun(t *testing.T) { events := collectRuntimeEvents(service.Events()) // Verify StopReason is error and specifies the streak limit - assertEventContains(t, events, EventStopReasonDecided) - - for _, e := range events { - if e.Type == EventStopReasonDecided { - payload := e.Payload.(StopReasonDecidedPayload) - if payload.Reason != controlplane.StopReasonError { - t.Errorf("expected StopReasonError, got %s", payload.Reason) - } - if payload.Detail != ErrNoProgressStreakLimit.Error() { - t.Errorf("expected detail to be %q, got %q", ErrNoProgressStreakLimit.Error(), payload.Detail) - } - } - } + assertStopReasonDecided(t, events, controlplane.StopReasonError, ErrNoProgressStreakLimit.Error()) if !promptInjected { t.Error("expected self-healing prompt to be injected before streak limit is reached, but it wasn't") @@ -119,13 +114,19 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { } var providerCalls int32 + var signatureSeq int32 providerFactory := &scriptedProviderFactory{ provider: &scriptedProvider{ chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { call := int(atomic.AddInt32(&providerCalls, 1)) if call <= 4 { + seq := atomic.AddInt32(&signatureSeq, 1) events <- providertypes.NewToolCallStartStreamEvent(0, "call_mixed", "tool_mixed") - events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_mixed", "{}") + events <- providertypes.NewToolCallDeltaStreamEvent( + 0, + "call_mixed", + `{"seq":`+strconv.FormatInt(int64(seq), 10)+`}`, + ) events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) return nil } @@ -161,14 +162,7 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - for _, e := range events { - if e.Type == EventStopReasonDecided { - payload := e.Payload.(StopReasonDecidedPayload) - if payload.Reason != controlplane.StopReasonSuccess { - t.Fatalf("expected stop reason success, got %s", payload.Reason) - } - } - } + assertStopReasonDecided(t, events, controlplane.StopReasonSuccess, "") } func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { @@ -184,11 +178,14 @@ func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { }, } + var executeCalls int32 + var providerCalls int32 toolManager := &stubToolManager{ specs: []providertypes.ToolSpec{ {Name: "tool_repeat"}, }, executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + atomic.AddInt32(&executeCalls, 1) return tools.ToolResult{Name: input.Name, Content: "ok", IsError: false}, nil }, } @@ -197,6 +194,7 @@ func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { providerFactory := &scriptedProviderFactory{ provider: &scriptedProvider{ chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + atomic.AddInt32(&providerCalls, 1) if strings.Contains(req.SystemPrompt, selfHealingRepeatReminder) { promptInjected = true } @@ -230,22 +228,78 @@ func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { events := collectRuntimeEvents(service.Events()) - assertEventContains(t, events, EventStopReasonDecided) - for _, e := range events { - if e.Type == EventStopReasonDecided { - payload := e.Payload.(StopReasonDecidedPayload) - if payload.Reason != controlplane.StopReasonError { - t.Errorf("expected StopReasonError, got %s", payload.Reason) - } - if payload.Detail != ErrRepeatCycleLimit.Error() { - t.Errorf("expected detail to be %q, got %q", ErrRepeatCycleLimit.Error(), payload.Detail) - } - } - } + assertStopReasonDecided(t, events, controlplane.StopReasonError, ErrRepeatCycleLimit.Error()) if !promptInjected { t.Fatal("expected repeat self-healing prompt to be injected before repeat limit is reached") } + if executeCalls != 3 { + t.Fatalf("expected break on the 3rd identical tool execution, got %d", executeCalls) + } + if providerCalls != 3 { + t.Fatalf("expected 3 provider turns before repeat breaker, got %d", providerCalls) + } +} + +func TestRepeatCycleStreakCountsFailedToolCalls(t *testing.T) { + t.Setenv("TEST_KEY", "dummy") + + cfg := config.Config{ + Providers: []config.ProviderConfig{{Name: "test-repeat-fail", Driver: "test", BaseURL: "http://localhost", Model: "test", APIKeyEnv: "TEST_KEY"}}, + SelectedProvider: "test-repeat-fail", + Workdir: t.TempDir(), + Runtime: config.RuntimeConfig{ + MaxNoProgressStreak: 10, + MaxRepeatCycleStreak: 3, + }, + } + + var executeCalls int32 + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{ + {Name: "tool_repeat_fail"}, + }, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + atomic.AddInt32(&executeCalls, 1) + return tools.ToolResult{Name: input.Name, Content: "error", IsError: true}, nil + }, + } + + var providerCalls int32 + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + atomic.AddInt32(&providerCalls, 1) + events <- providertypes.NewToolCallStartStreamEvent(0, "call_repeat_fail", "tool_repeat_fail") + events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_repeat_fail", `{"path":"x"}`) + events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) + return nil + }, + }, + } + + manager := config.NewManager(config.NewLoader(t.TempDir(), &cfg)) + service := NewWithFactory( + manager, + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + err := service.Run(context.Background(), UserInput{ + RunID: "run-repeat-fail-streak", + Content: "trigger repeat fail loop", + }) + if !errors.Is(err, ErrRepeatCycleLimit) { + t.Fatalf("expected ErrRepeatCycleLimit, got %v", err) + } + if executeCalls != 3 { + t.Fatalf("expected failed repeated calls to break on the 3rd execution, got %d", executeCalls) + } + if providerCalls != 3 { + t.Fatalf("expected 3 provider turns before repeat breaker, got %d", providerCalls) + } } func TestComputeToolSignatureNormalizationAndFallback(t *testing.T) { @@ -360,3 +414,20 @@ func TestResolveStreakLimitDefaults(t *testing.T) { t.Fatalf("expected explicit repeat limit 6, got %d", got) } } + +func assertStopReasonDecided(t *testing.T, events []RuntimeEvent, wantReason controlplane.StopReason, wantDetail string) { + t.Helper() + assertEventContains(t, events, EventStopReasonDecided) + for _, e := range events { + if e.Type != EventStopReasonDecided { + continue + } + payload := e.Payload.(StopReasonDecidedPayload) + if payload.Reason != wantReason { + t.Fatalf("expected stop reason %s, got %s", wantReason, payload.Reason) + } + if wantDetail != "" && payload.Detail != wantDetail { + t.Fatalf("expected detail to be %q, got %q", wantDetail, payload.Detail) + } + } +}