diff --git a/.gitignore b/.gitignore index ec7f5455c..882a4934f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ web/build/* !web/build/icon.icns web/release/ web/dist-electron/ +/.baoyu-skills/ diff --git a/internal/checkpoint/checkpoint_coverage_test.go b/internal/checkpoint/checkpoint_coverage_test.go new file mode 100644 index 000000000..13cda47fb --- /dev/null +++ b/internal/checkpoint/checkpoint_coverage_test.go @@ -0,0 +1,525 @@ +package checkpoint + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestFinalizeExactForCheckpointsEmptyID(t *testing.T) { + store, _ := newTestStore(t) + ok, err := store.FinalizeExactForCheckpoints(" ", []string{"cp1"}) + if err == nil || !strings.Contains(err.Error(), "empty checkpointID") { + t.Fatalf("expected empty checkpointID error, got ok=%v err=%v", ok, err) + } +} + +func TestFinalizeExactForCheckpointsEmptyRelated(t *testing.T) { + store, _ := newTestStore(t) + ok, err := store.FinalizeExactForCheckpoints("cp-end", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false for empty related list") + } +} + +func TestFinalizeExactForCheckpointsWithEmptyRelatedIDs(t *testing.T) { + store, _ := newTestStore(t) + ok, err := store.FinalizeExactForCheckpoints("cp-end", []string{"", " "}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false for all-empty related IDs") + } +} + +func TestFinalizeExactForCheckpointsMergesMultipleCheckpoints(t *testing.T) { + store, workdir := newTestStore(t) + abs1 := writeWorkdirFile(t, workdir, "a.txt", "a-initial") + abs2 := writeWorkdirFile(t, workdir, "b.txt", "b-initial") + + // Capture + finalize for a.txt (cp1) + if _, err := store.CapturePreWrite(abs1); err != nil { + t.Fatalf("capture a.txt: %v", err) + } + if err := os.WriteFile(abs1, []byte("a-after-cp1"), 0o644); err != nil { + t.Fatalf("edit a.txt: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // Capture + finalize for b.txt (cp2) + if _, err := store.CapturePreWrite(abs2); err != nil { + t.Fatalf("capture b.txt: %v", err) + } + if err := os.WriteFile(abs2, []byte("b-after-cp2"), 0o644); err != nil { + t.Fatalf("edit b.txt: %v", err) + } + if _, err := store.Finalize("cp2"); err != nil { + t.Fatalf("finalize cp2: %v", err) + } + store.Reset() + + // FinalizeExactForCheckpoints 合并 cp1 和 cp2 的状态 + ok, err := store.FinalizeExactForCheckpoints("cp-end", []string{"cp1", "cp2"}) + if err != nil { + t.Fatalf("FinalizeExactForCheckpoints error: %v", err) + } + if !ok { + t.Fatal("expected true when merging valid checkpoints") + } + + // 验证 cp-end 的 meta 存在且包含两个文件的版本 + meta, err := store.readCheckpointMeta("cp-end") + if err != nil { + t.Fatalf("readCheckpointMeta cp-end: %v", err) + } + if len(meta.ExactFileVersions) < 2 { + t.Fatalf("expected at least 2 exact file versions, got %d", len(meta.ExactFileVersions)) + } + if len(meta.FileVersions) < 2 { + t.Fatalf("expected at least 2 file versions, got %d", len(meta.FileVersions)) + } +} + +func TestFinalizeExactForCheckpointsNonexistentCheckpoint(t *testing.T) { + store, _ := newTestStore(t) + _, err := store.FinalizeExactForCheckpoints("cp-end", []string{"nonexistent"}) + if err == nil { + t.Fatal("expected error for nonexistent related checkpoint") + } +} + +func TestFinalizeExactForCheckpointsSkipsEmptyHashVersions(t *testing.T) { + store, workdir := newTestStore(t) + abs := writeWorkdirFile(t, workdir, "a.txt", "initial") + + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + if err := os.WriteFile(abs, []byte("after"), 0o644); err != nil { + t.Fatalf("edit: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // cp1 应该有 FileVersions + ok, err := store.FinalizeExactForCheckpoints("cp-end", []string{"cp1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected true for single valid checkpoint") + } +} + +func TestRunEndCaptureEmptyList(t *testing.T) { + store, _ := newTestStore(t) + if err := store.RunEndCapture(context.Background(), nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := store.RunEndCapture(context.Background(), []string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunEndCaptureWithCheckpoints(t *testing.T) { + store, workdir := newTestStore(t) + abs1 := writeWorkdirFile(t, workdir, "a.txt", "a-v0") + abs2 := writeWorkdirFile(t, workdir, "b.txt", "b-v0") + + // Capture a.txt (turn 1) + if _, err := store.CapturePreWrite(abs1); err != nil { + t.Fatalf("capture a.txt: %v", err) + } + if err := os.WriteFile(abs1, []byte("a-v1"), 0o644); err != nil { + t.Fatalf("edit a.txt: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // Capture b.txt (turn 2) + if _, err := store.CapturePreWrite(abs2); err != nil { + t.Fatalf("capture b.txt: %v", err) + } + if err := os.WriteFile(abs2, []byte("b-v1"), 0o644); err != nil { + t.Fatalf("edit b.txt: %v", err) + } + if _, err := store.Finalize("cp2"); err != nil { + t.Fatalf("finalize cp2: %v", err) + } + store.Reset() + + // RunEndCapture 应该对 cp1 和 cp2 中所有涉及的文件抓取当前状态 + if err := store.RunEndCapture(context.Background(), []string{"cp1", "cp2"}); err != nil { + t.Fatalf("RunEndCapture error: %v", err) + } +} + +func TestRunEndCaptureContextCancelled(t *testing.T) { + store, workdir := newTestStore(t) + abs := writeWorkdirFile(t, workdir, "a.txt", "v0") + + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + if err := os.WriteFile(abs, []byte("v1"), 0o644); err != nil { + t.Fatalf("edit: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize: %v", err) + } + store.Reset() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := store.RunEndCapture(ctx, []string{"cp1"}) + if err == nil { + t.Fatal("expected context cancelled error") + } +} + +func TestRunEndCaptureSkipsNonexistentCheckpoints(t *testing.T) { + store, workdir := newTestStore(t) + abs := writeWorkdirFile(t, workdir, "a.txt", "v0") + + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // 混合存在和不存在的 checkpoint + if err := store.RunEndCapture(context.Background(), []string{"cp1", "nonexistent"}); err != nil { + t.Fatalf("RunEndCapture error: %v", err) + } +} + +func TestRunEndCaptureAllNonexistent(t *testing.T) { + store, _ := newTestStore(t) + if err := store.RunEndCapture(context.Background(), []string{"nonexistent1", "nonexistent2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCapturePostDelete(t *testing.T) { + store, workdir := newTestStore(t) + abs := writeWorkdirFile(t, workdir, "todelete.txt", "will be deleted") + + // CapturePreWrite first to register the path (version 1) + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + // Delete the file + if err := os.Remove(abs); err != nil { + t.Fatalf("remove: %v", err) + } + // CapturePostDelete creates a new version for the deletion + if err := store.CapturePostDelete([]string{abs}); err != nil { + t.Fatalf("CapturePostDelete: %v", err) + } + + // CapturePostDelete 会为已注册 path 创建新版本(version 2) + hash := perEditPathHash(abs) + meta, err := store.readVersionMeta(hash, 2) + if err != nil { + t.Fatalf("readVersionMeta: %v", err) + } + if !meta.IsPostDelete { + t.Fatal("expected IsPostDelete=true") + } +} + +func TestHasPending(t *testing.T) { + store, workdir := newTestStore(t) + if store.HasPending() { + t.Fatal("expected no pending after creation") + } + + abs := writeWorkdirFile(t, workdir, "a.txt", "v0") + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + if !store.HasPending() { + t.Fatal("expected pending after capture") + } + + store.Reset() + if store.HasPending() { + t.Fatal("expected no pending after reset") + } +} + +func TestDeleteCheckpoint(t *testing.T) { + store, workdir := newTestStore(t) + abs := writeWorkdirFile(t, workdir, "a.txt", "v0") + + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + if _, err := store.Finalize("cp-to-delete"); err != nil { + t.Fatalf("finalize: %v", err) + } + store.Reset() + + // 确认 checkpoint 存在 + _, err := store.readCheckpointMeta("cp-to-delete") + if err != nil { + t.Fatalf("should exist before delete: %v", err) + } + + // 删除 checkpoint 元数据 + if err := store.DeleteCheckpoint("cp-to-delete"); err != nil { + t.Fatalf("DeleteCheckpoint error: %v", err) + } + + // 确认已删除(readCheckpointMeta 应该失败) + _, err = store.readCheckpointMeta("cp-to-delete") + if err == nil { + t.Fatal("expected error reading deleted checkpoint meta") + } +} + +func TestDeleteCheckpointNonexistent(t *testing.T) { + store, _ := newTestStore(t) + if err := store.DeleteCheckpoint("nonexistent"); err != nil { + t.Fatalf("expected no error for deleting nonexistent checkpoint, got %v", err) + } +} + +func TestFinalizeSingleFileSkipWhenNoPending(t *testing.T) { + store, _ := newTestStore(t) + ok, err := store.Finalize("cp-no-pending") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false when no pending captures") + } +} + +func TestFinalizeWithExactStatePreservesFile(t *testing.T) { + store, workdir := newTestStore(t) + abs := writeWorkdirFile(t, workdir, "f.txt", "initial") + + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + if err := os.WriteFile(abs, []byte("after-edit"), 0o644); err != nil { + t.Fatalf("edit: %v", err) + } + ok, err := store.FinalizeWithExactState("cp-exact") + if err != nil { + t.Fatalf("FinalizeWithExactState error: %v", err) + } + if !ok { + t.Fatal("expected true for finalize with pending") + } + + meta, err := store.readCheckpointMeta("cp-exact") + if err != nil { + t.Fatalf("read meta: %v", err) + } + if len(meta.ExactFileVersions) == 0 { + t.Fatal("expected ExactFileVersions to be non-empty") + } +} + +func TestRefForPerEditCheckpointRoundtrip(t *testing.T) { + ref := RefForPerEditCheckpoint("abc123") + if ref != "peredit:abc123" { + t.Fatalf("RefForPerEditCheckpoint = %q", ref) + } + if !IsPerEditRef(ref) { + t.Fatal("expected IsPerEditRef=true") + } + if id := PerEditCheckpointIDFromRef(ref); id != "abc123" { + t.Fatalf("PerEditCheckpointIDFromRef = %q", id) + } +} + +func TestIsPerEditRefOddCases(t *testing.T) { + if IsPerEditRef("") { + t.Fatal("empty string is not peredit ref") + } + if IsPerEditRef("peredit") { + t.Fatal("bare peredit is not a valid ref") + } + // peredit: with empty ID — depends on implementation +} + +func TestBashHasArchiveExtractFlag(t *testing.T) { + // tar with extract flag + if !bashHasArchiveExtractFlag("tar -xzf bundle.tar.gz") { + t.Fatal("expected true for tar -xzf") + } + if !bashHasArchiveExtractFlag("tar xvf bundle.tar") { + t.Fatal("expected true for tar xvf") + } + if !bashHasArchiveExtractFlag("tar --extract -f bundle.tar") { + t.Fatal("expected true for tar --extract") + } + // tar without extract flag should be false + if bashHasArchiveExtractFlag("tar -czf bundle.tar.gz src/") { + t.Fatal("expected false for tar -czf (create)") + } + // unzip/gunzip/bunzip2 always extract + if !bashHasArchiveExtractFlag("unzip file.zip") { + t.Fatal("expected true for unzip") + } + if !bashHasArchiveExtractFlag("gunzip file.gz") { + t.Fatal("expected true for gunzip") + } +} + +func TestHasRecognizedSourceExtEdgeCases(t *testing.T) { + // standard extensions + if !hasRecognizedSourceExt("main.go") { + t.Fatal("expected true for .go") + } + if !hasRecognizedSourceExt("README.md") { + t.Fatal("expected true for .md") + } + // no extension but recognized basename + if !hasRecognizedSourceExt("Dockerfile") { + t.Fatal("expected true for Dockerfile") + } + if !hasRecognizedSourceExt("Makefile") { + t.Fatal("expected true for Makefile") + } + if hasRecognizedSourceExt(".gitignore") { + t.Fatal("expected false for .gitignore (has extension, not in source set)") + } + // unrecognized + if hasRecognizedSourceExt("image.png") { + t.Fatal("expected false for .png") + } + if hasRecognizedSourceExt("binary") { + t.Fatal("expected false for extensionless unrecognized file") + } +} + +func TestTokenizeBashArgsEdgeCases(t *testing.T) { + // empty + if len(tokenizeBashArgs("")) != 0 { + t.Fatal("expected no tokens for empty string") + } + // simple command + tokens := tokenizeBashArgs("git checkout main") + if len(tokens) < 2 { + t.Fatalf("expected at least 2 tokens, got %d: %v", len(tokens), tokens) + } + // quoted args get stripped of quotes by tokenizer + tokens = tokenizeBashArgs(`echo "hello world"`) + if len(tokens) < 2 { + t.Fatalf("expected at least 2 tokens, got %d: %v", len(tokens), tokens) + } + // pipe splits tokens + tokens = tokenizeBashArgs("cat a.txt | grep foo") + if len(tokens) < 3 { + t.Fatalf("expected at least 3 tokens, got %d: %v", len(tokens), tokens) + } +} + +func TestResolvePathAgainstWorkdirEdgeCases(t *testing.T) { + // empty path with empty workdir returns "" + if got := resolvePathAgainstWorkdir("", "."); got != "" { + t.Fatalf("expected empty for empty path with dot workdir, got %q", got) + } + // path with glob + if got := resolvePathAgainstWorkdir("*.go", "/tmp"); got != "" { + t.Fatalf("expected empty for glob, got %q", got) + } + // dot workdir + if got := resolvePathAgainstWorkdir("file.go", "."); got != "" { + t.Fatalf("expected empty for dot workdir, got %q", got) + } + // path escaping workdir + got := resolvePathAgainstWorkdir("../outside.txt", "/tmp/work") + if got != "" { + t.Fatalf("expected empty for escape path, got %q", got) + } + // absolute path within workdir + got = resolvePathAgainstWorkdir("/tmp/work/file.go", "/tmp/work") + if got == "" { + t.Fatal("expected non-empty for abs path within workdir") + } +} + +func TestSourceFilesInWorkdirEdgeCases(t *testing.T) { + // empty command + if len(SourceFilesInWorkdir("", "/tmp")) != 0 { + t.Fatal("expected empty for empty command") + } + // command with source file-like tokens + files := SourceFilesInWorkdir("cat main.go", "/tmp/work") + if len(files) != 0 { + // 应该返回空因为 /tmp/work/main.go 不存在 + t.Logf("files: %v", files) + } + // command with no recognized extensions + files = SourceFilesInWorkdir("ls -la", "/tmp") + if len(files) != 0 { + t.Fatalf("expected empty for ls, got %v", files) + } +} + +func TestBashLikelyWritesFilesEmptyCommand(t *testing.T) { + if BashLikelyWritesFiles("") { + t.Fatal("expected false for empty command") + } + if BashLikelyWritesFiles(" ") { + t.Fatal("expected false for whitespace command") + } +} + +func TestBashLikelyWritesFilesReadOnlyCommands(t *testing.T) { + readOnly := []string{ + "ls -la", + "cat file.go", + "grep pattern *.go", + "head -20 file.txt", + "tail -f log.txt", + "wc -l file.go", + "sort data.txt", + "uniq list.txt", + "du -sh .", + "df -h", + "echo hello", + "pwd", + "whoami", + "date", + "env", + "which go", + "go version", + "git status", + "git log --oneline", + "git diff", + "git branch", + } + for _, cmd := range readOnly { + if BashLikelyWritesFiles(cmd) { + t.Errorf("expected false for read-only %q", cmd) + } + } +} + +func TestStripHarmlessRedirectsPreservesDangerous(t *testing.T) { + got := stripHarmlessRedirects("echo hello > file.txt") + if !strings.Contains(got, "> file.txt") { + t.Fatalf("expected dangerous redirect preserved, got %q", got) + } +} diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index d56eeb0a1..d4cee138c 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1737,9 +1737,6 @@ func convertRuntimeSnapshot(snapshot agentruntime.RuntimeSnapshot) gateway.Runti Phase: strings.TrimSpace(snapshot.Phase), UpdatedAt: snapshot.UpdatedAt, Todos: convertRuntimeTodoSnapshot(snapshot.Todos), - Facts: map[string]any{ - "runtime_facts": snapshot.Facts.RuntimeFacts, - }, Decision: map[string]any{ "status": strings.TrimSpace(snapshot.Decision.Status), "stop_reason": strings.TrimSpace(snapshot.Decision.StopReason), diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index ee691eaf5..a7395649d 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -1050,7 +1050,6 @@ func TestGatewayRuntimePortBridgeListSessionTodosAndSnapshot(t *testing.T) { SessionID: "session-2", Phase: "acceptance", Decision: agentruntime.DecisionSnapshot{Status: "continue", StopReason: "unverified_write"}, - SubAgents: agentruntime.SubAgentSnapshot{StartedCount: 1, CompletedCount: 1, FailedCount: 0}, }, } bridge, err := newGatewayRuntimePortBridge(context.Background(), stub, testSessionStore) diff --git a/internal/config/runtime_hooks.go b/internal/config/runtime_hooks.go index d02e45064..b793f3e7b 100644 --- a/internal/config/runtime_hooks.go +++ b/internal/config/runtime_hooks.go @@ -25,6 +25,7 @@ const ( const ( runtimeHookScopeUser = "user" runtimeHookKindBuiltIn = "builtin" + runtimeHookKindCommand = "command" runtimeHookKindHTTP = "http" runtimeHookModeSync = "sync" runtimeHookModeObserve = "observe" @@ -41,6 +42,7 @@ const ( runtimeHookPointBeforeToolCall = "before_tool_call" runtimeHookPointAfterToolResult = "after_tool_result" runtimeHookPointBeforeCompletionDecision = "before_completion_decision" + runtimeHookPointAcceptGate = "accept_gate" runtimeHookPointBeforePermissionDecision = "before_permission_decision" runtimeHookPointAfterToolFailure = "after_tool_failure" runtimeHookPointSessionStart = "session_start" @@ -249,6 +251,7 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error { case runtimeHookPointBeforeToolCall, runtimeHookPointAfterToolResult, runtimeHookPointBeforeCompletionDecision, + runtimeHookPointAcceptGate, runtimeHookPointBeforePermissionDecision, runtimeHookPointAfterToolFailure, runtimeHookPointSessionStart, @@ -271,11 +274,12 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error { normalizedKind := strings.ToLower(strings.TrimSpace(c.Kind)) switch normalizedKind { case runtimeHookKindBuiltIn: + case runtimeHookKindCommand: case runtimeHookKindHTTP: default: if _, external := runtimeHookExternalKinds[normalizedKind]; external { return fmt.Errorf( - "external hook kind %q is not supported in P6-lite; only builtin/http-observe hooks are enabled", + "external hook kind %q is not supported in current stage; only builtin/command/http-observe hooks are enabled", c.Kind, ) } @@ -306,6 +310,13 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error { if handler == runtimeHookHandlerWarnOnToolCall && !hasWarnOnToolCallTargets(c.Params) { return fmt.Errorf("handler %q requires params.tool_name or params.tool_names", c.Handler) } + case runtimeHookKindCommand: + if normalizedMode != runtimeHookModeSync { + return fmt.Errorf("mode %q is not supported for kind command (only sync)", c.Mode) + } + if strings.TrimSpace(readRuntimeHookParamString(c.Params, "command")) == "" { + return fmt.Errorf("kind command requires params.command") + } case runtimeHookKindHTTP: if normalizedMode != runtimeHookModeObserve { return fmt.Errorf("mode %q is not supported for kind http (only observe)", c.Mode) diff --git a/internal/config/runtime_hooks_test.go b/internal/config/runtime_hooks_test.go index adeb5017f..f039d8f0e 100644 --- a/internal/config/runtime_hooks_test.go +++ b/internal/config/runtime_hooks_test.go @@ -105,7 +105,7 @@ func TestRuntimeHooksConfigValidateRejectsExternalKindsWithP6LiteMessage(t *test DefaultTimeoutSec: 2, DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly, } - externalKinds := []string{"command", "prompt", "agent"} + externalKinds := []string{"prompt", "agent"} for _, kind := range externalKinds { kind := kind t.Run(kind, func(t *testing.T) { @@ -126,13 +126,39 @@ func TestRuntimeHooksConfigValidateRejectsExternalKindsWithP6LiteMessage(t *test if err == nil { t.Fatalf("expected external kind %q to be rejected", kind) } - if !strings.Contains(err.Error(), "not supported in P6-lite") { - t.Fatalf("error=%q, want contains not supported in P6-lite", err.Error()) + if !strings.Contains(err.Error(), "not supported in current stage") { + t.Fatalf("error=%q, want contains not supported in current stage", err.Error()) } }) } } +func TestRuntimeHooksConfigValidateAllowsCommand(t *testing.T) { + t.Parallel() + + cfg := RuntimeHooksConfig{ + Enabled: boolPtr(true), + UserHooksEnabled: boolPtr(true), + DefaultTimeoutSec: 2, + DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly, + Items: []RuntimeHookItemConfig{ + { + ID: "accept-command", + Point: runtimeHookPointAcceptGate, + Scope: runtimeHookScopeUser, + Kind: runtimeHookKindCommand, + Mode: runtimeHookModeSync, + TimeoutSec: 2, + FailurePolicy: runtimeHookFailurePolicyWarnOnly, + Params: map[string]any{"command": "echo ok"}, + }, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + func TestRuntimeHooksConfigValidateAllowsHTTPObserve(t *testing.T) { t.Parallel() diff --git a/internal/context/accept_checks_test.go b/internal/context/accept_checks_test.go deleted file mode 100644 index d3c88a4d8..000000000 --- a/internal/context/accept_checks_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package context - -import agentsession "neo-code/internal/session" - -func acceptText(items ...string) agentsession.AcceptChecks { - out := make(agentsession.AcceptChecks, 0, len(items)) - for _, item := range items { - out = append(out, agentsession.AcceptCheck{Kind: agentsession.AcceptCheckOutputOnly, Target: item}) - } - return out -} diff --git a/internal/context/ask_prompt_test.go b/internal/context/ask_prompt_test.go index 7e9dc7acb..3603f919e 100644 --- a/internal/context/ask_prompt_test.go +++ b/internal/context/ask_prompt_test.go @@ -75,6 +75,188 @@ func TestBuildAskPromptHonorsTokenTrim(t *testing.T) { } } +func TestBuildAskPromptEmptyQuery(t *testing.T) { + got := BuildAskPrompt([]AskTurn{{UserQuery: "q1", Assistant: "a1"}}, " ", AskPromptConfig{}) + if got.Prompt != "" { + t.Fatalf("expected empty prompt for empty query, got %q", got.Prompt) + } +} + +func TestBuildAskPromptSummaryEdgeCases(t *testing.T) { + // 空轮次 + if got := buildAskPromptSummary(nil, 100); got != "" { + t.Fatalf("expected empty summary for nil turns, got %q", got) + } + // maxChars <= 0 + if got := buildAskPromptSummary([]AskTurn{{UserQuery: "q", Assistant: "a"}}, 0); got != "" { + t.Fatalf("expected empty summary for zero maxChars, got %q", got) + } + // 正常摘要 + got := buildAskPromptSummary([]AskTurn{ + {UserQuery: "q1", Assistant: "a1"}, + {UserQuery: "q2", Assistant: "a2"}, + }, 1000) + if !strings.Contains(got, "Q: q1") { + t.Fatalf("expected Q: q1 in summary, got %q", got) + } + if !strings.Contains(got, "A: a1") { + t.Fatalf("expected A: a1 in summary, got %q", got) + } +} + +func TestCompactAskTurnsEdgeCases(t *testing.T) { + // 空历史 + sum, ret := compactAskTurns(nil, 5) + if len(sum) != 0 || len(ret) != 0 { + t.Fatalf("expected empty for nil history, got %d/%d", len(sum), len(ret)) + } + + // retainTurns <= 0 使用默认值 1,2条历史保留1条 + history := []AskTurn{ + {UserQuery: "q1", Assistant: "a1"}, + {UserQuery: "q2", Assistant: "a2"}, + } + sum, ret = compactAskTurns(history, 0) + if len(ret) != 1 { + t.Fatalf("expected 1 retained (default retainTurns=1), got %d", len(ret)) + } + if len(sum) != 1 { + t.Fatalf("expected 1 summary turn, got %d", len(sum)) + } + + // retainTurns >= len(history) 全部保留 + sum, ret = compactAskTurns(history, 10) + if len(sum) != 0 { + t.Fatalf("expected no summary turns, got %d", len(sum)) + } + if len(ret) != 2 { + t.Fatalf("expected all 2 retained, got %d", len(ret)) + } + + // 正常分拆 + history = []AskTurn{ + {UserQuery: "q1", Assistant: "a1"}, + {UserQuery: "q2", Assistant: "a2"}, + {UserQuery: "q3", Assistant: "a3"}, + {UserQuery: "q4", Assistant: "a4"}, + } + sum, ret = compactAskTurns(history, 2) + if len(sum) != 2 { + t.Fatalf("expected 2 summary turns, got %d", len(sum)) + } + if len(ret) != 2 { + t.Fatalf("expected 2 retained turns, got %d", len(ret)) + } +} + +func TestComposeAskPromptEdgeCases(t *testing.T) { + // 空当前问题 + if got := composeAskPrompt("summary", []AskTurn{{UserQuery: "q1"}}, ""); got != "" { + t.Fatalf("expected empty for empty query, got %q", got) + } + + // 仅当前问题(无摘要无历史) + got := composeAskPrompt("", nil, "just a question") + if got != "just a question" { + t.Fatalf("expected plain question, got %q", got) + } + + // 有摘要有历史有当前问题 + got = composeAskPrompt("summary text", []AskTurn{{UserQuery: "q1", Assistant: "a1"}}, "current?") + if !strings.Contains(got, "Current question") { + t.Fatalf("expected Current question section, got %q", got) + } + if !strings.Contains(got, "Summary") { + t.Fatalf("expected Summary section, got %q", got) + } + if !strings.Contains(got, "Recent turns") { + t.Fatalf("expected Recent turns section, got %q", got) + } +} + +func TestTrimAskPromptEdgeCases(t *testing.T) { + query := "current question?" + + // prompt 在限制内 + shortPrompt := "short prompt\n\nCurrent question:\n" + query + got := trimAskPrompt(shortPrompt, query, 10000, 500) + if got != shortPrompt { + t.Fatalf("expected unchanged prompt, got %q", got) + } + + // prompt 超过限制,有 summaryMaxChars + longPrompt := strings.Repeat("very long prompt text ", 200) + got = trimAskPrompt(longPrompt, query, 100, 50) + if got == longPrompt { + t.Fatal("expected trimmed prompt") + } + if !strings.Contains(got, "Current question") { + t.Fatalf("expected Current question in trimmed prompt, got %q", got) + } + + // 仅保留当前问题部分 + veryLongPrompt := strings.Repeat("x", 5000) + got = trimAskPrompt(veryLongPrompt, query, len([]rune("Current question:\n"))+len([]rune(query)), 0) + if !strings.Contains(got, query) { + t.Fatalf("expected current question in fallback, got %q", got) + } + + // 极端情况:maxChars 非常小 + got = trimAskPrompt("long prompt here", "long question", 5, 0) + if len([]rune(got)) > 5 { + t.Fatalf("expected very short prompt, got %q (%d runes)", got, len([]rune(got))) + } +} + +func TestTrimTextByRunesEdgeCases(t *testing.T) { + // 空字符串 + if got := trimTextByRunes("", 10); got != "" { + t.Fatalf("expected empty for empty string, got %q", got) + } + // maxRunes <= 0 + if got := trimTextByRunes("hello", 0); got != "" { + t.Fatalf("expected empty for zero maxRunes, got %q", got) + } + // 文本在限制内 + if got := trimTextByRunes("hello", 10); got != "hello" { + t.Fatalf("expected unchanged, got %q", got) + } + // 文本需要裁剪 + got := trimTextByRunes("hello world this is long", 10) + if len([]rune(got)) > 10 { + t.Fatalf("expected <= 10 runes, got %d: %q", len([]rune(got)), got) + } + if !strings.HasSuffix(got, "...") { + t.Fatalf("expected ... suffix, got %q", got) + } + // maxRunes <= 3: no ellipsis + got = trimTextByRunes("hello world", 3) + if strings.HasSuffix(got, "...") { + t.Fatalf("expected no ellipsis for maxRunes <= 3, got %q", got) + } +} + +func TestEstimateTokenCountByRunes(t *testing.T) { + // 空字符串 + if got := estimateTokenCountByRunes(""); got != 0 { + t.Fatalf("expected 0 for empty, got %d", got) + } + // 空白字符串 + if got := estimateTokenCountByRunes(" "); got != 0 { + t.Fatalf("expected 0 for whitespace, got %d", got) + } + // 短文本 + got := estimateTokenCountByRunes("hello") + if got < 1 { + t.Fatalf("expected positive token count, got %d", got) + } + // 长文本 + got = estimateTokenCountByRunes(strings.Repeat("a", 4000)) + if got != 1000 { // (4000+3)/4 = 1000 + t.Fatalf("expected 1000, got %d", got) + } +} + func TestNormalizeAskPromptConfigUsesDefaults(t *testing.T) { got := normalizeAskPromptConfig(AskPromptConfig{}) if got.MaxInputTokens != config.DefaultAskMaxInputTokens { diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 63ae35c76..db2c1cd8f 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -159,13 +159,11 @@ func TestDefaultBuilderBuildIncludesPlanSections(t *testing.T) { Goal: "引入 plan/build 模式", Steps: []string{"扩展 session", "扩展 runtime"}, Constraints: []string{"保持 tools 边界"}, - Verify: acceptText("go test ./internal/..."), }, Summary: agentsession.SummaryView{ Goal: "引入 plan/build 模式", KeySteps: []string{"扩展 session", "扩展 runtime"}, Constraints: []string{"保持 tools 边界"}, - Verify: acceptText("go test ./internal/..."), ActiveTodoIDs: []string{"todo-1"}, }, }, diff --git a/internal/context/source_plan_mode.go b/internal/context/source_plan_mode.go index 910a54250..59ab26d67 100644 --- a/internal/context/source_plan_mode.go +++ b/internal/context/source_plan_mode.go @@ -85,12 +85,6 @@ func renderCurrentPlanSection(plan *agentsession.PlanArtifact, injectFull bool) lines = append(lines, "- "+constraint) } } - if len(plan.Summary.Verify) > 0 { - lines = append(lines, "verify:") - for _, check := range plan.Summary.Verify.RenderLines() { - lines = append(lines, "- "+check) - } - } if len(plan.Summary.ActiveTodoIDs) > 0 { lines = append(lines, "active_todo_ids:") for _, todoID := range plan.Summary.ActiveTodoIDs { diff --git a/internal/context/source_plan_mode_test.go b/internal/context/source_plan_mode_test.go index 39adf98e6..25cae39d3 100644 --- a/internal/context/source_plan_mode_test.go +++ b/internal/context/source_plan_mode_test.go @@ -111,7 +111,6 @@ func TestRenderCurrentPlanSectionInjectsFullPlan(t *testing.T) { Spec: agentsession.PlanSpec{ Goal: "完整计划", Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), OpenQuestions: []string{"问题一"}, }, Summary: agentsession.SummaryView{ diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 24e44be3f..9812b0f85 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -765,7 +765,6 @@ type RuntimeSnapshot struct { TaskKind string `json:"task_kind,omitempty"` UpdatedAt time.Time `json:"updated_at"` Todos TodoSnapshot `json:"todos"` - Facts map[string]any `json:"facts,omitempty"` Decision map[string]any `json:"decision,omitempty"` SubAgents map[string]any `json:"subagents,omitempty"` PendingUserQuestion *PendingUserQuestionSnapshot `json:"pending_user_question,omitempty"` diff --git a/internal/promptasset/templates/context/plan_mode_build_execute.md b/internal/promptasset/templates/context/plan_mode_build_execute.md index 7b72ff74b..b0d0fdfd0 100644 --- a/internal/promptasset/templates/context/plan_mode_build_execute.md +++ b/internal/promptasset/templates/context/plan_mode_build_execute.md @@ -15,4 +15,4 @@ You are currently in build execution. - This applies to simple conversational inputs too, including greetings, casual chat, short Q&A, acknowledgements, open-ended offers for help, and inputs without an explicit actionable project request. - For simple conversational inputs or inputs without an explicit actionable request, answer briefly, do not call tools, and do not inspect or analyze the project just to make progress. - Do not stop working while you still have necessary tool calls to make. Tools take priority only when they are actually needed. -- Acceptance is terminal: your final answer enters a yes/no check against the plan's verify criteria. If it fails, the run ends — there is no retry. +- Acceptance is terminal: your final answer enters a completion check. If it fails, the run ends. diff --git a/internal/promptasset/templates/context/plan_mode_plan.md b/internal/promptasset/templates/context/plan_mode_plan.md index 0268dfdb3..0f6822b57 100644 --- a/internal/promptasset/templates/context/plan_mode_plan.md +++ b/internal/promptasset/templates/context/plan_mode_plan.md @@ -6,12 +6,9 @@ You are currently in the planning stage. - **If no Current Plan section is attached, your first priority is to produce a plan.** The user has entered planning mode expecting a structured plan. Research the codebase as needed, then output a complete `plan_spec` + `summary_candidate` JSON. Do not end the turn with only a conversational answer when there is no existing plan. - If a Current Plan is already present, you may refine, replace, or discuss it. When the user asks a clarifying question or wants to explore options without committing to a new plan revision, you may answer conversationally without outputting planning JSON. - Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan. -- `plan_spec` must include `goal`, `steps`, `constraints`, `verify`, `todos`, and `open_questions`. +- `plan_spec` must include `goal`, `steps`, `constraints`, `todos`, and `open_questions`. - `plan_spec.todos` **must not be empty**. Populate it with the major actionable items that the plan requires. Each todo must have a unique `id`, a descriptive `content`, and `status: "pending"`. Without todos the plan has no executable work items and the build stage cannot proceed. -- `summary_candidate` must include `goal`, `key_steps`, `constraints`, `verify`, and `active_todo_ids`. +- `summary_candidate` must include `goal`, `key_steps`, `constraints`, and `active_todo_ids`. - If a Todo State section is attached, decide which non-terminal todos still belong to the current plan. - Todos that still belong to the current plan must appear in `plan_spec.todos` and their IDs must appear in `summary_candidate.active_todo_ids`. - Todos that do not belong to the current plan must not be copied into the new plan; create replacement plan-owned todos when ongoing work is still needed. -- `verify` must be an array of structured check objects: `[{"kind":"...", "target":"...", "required":true}]`. -- Supported `kind` values: `output_only` (chat/read-only), `workspace_change` (writes/edits), `command_success` (build/test/lint), `file_exists` (file artifacts), `content_contains` (content checks), `tool_fact` (named tool facts). -- Examples: chat → `[{"kind":"output_only"}]`, fix → `[{"kind":"workspace_change"},{"kind":"command_success","target":"go test ./..."}]`, new file → `[{"kind":"workspace_change"},{"kind":"file_exists","target":"output.go"}]`. diff --git a/internal/promptasset/templates/core/agent_identity.md b/internal/promptasset/templates/core/agent_identity.md index 1547076c7..5eb050cbe 100644 --- a/internal/promptasset/templates/core/agent_identity.md +++ b/internal/promptasset/templates/core/agent_identity.md @@ -43,7 +43,7 @@ Your final answer is only a completion candidate. It does not by itself prove th Distinguish: - `completion_gate`: whether it is reasonable to attempt finalization. - `verification_gate`: whether the actual task requirements are satisfied. -- `acceptance_decision`: the runtime's final accepted/failed decision. Acceptance is terminal — there is no "continue" or retry. +- `acceptance_decision`: the runtime's final accepted/failed decision. Acceptance is terminal — there is no continue or retry. Do not finalize when any of these are true: - Required todos are pending, in progress, blocked, or failed. diff --git a/internal/provider/anthropic/error_test.go b/internal/provider/anthropic/error_test.go new file mode 100644 index 000000000..c7e1dbea6 --- /dev/null +++ b/internal/provider/anthropic/error_test.go @@ -0,0 +1,258 @@ +package anthropic + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +func TestMapAnthropicSDKErrorWithAPIError(t *testing.T) { + // 构造一个带 HTTP 429 的 API 错误场景 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = fmt.Fprint(w, `{"type":"error","error":{"type":"rate_limit_error","message":"Rate exceeded"}}`) + })) + defer server.Close() + + p, err := New(provider.RuntimeConfig{ + Driver: provider.DriverAnthropic, + BaseURL: server.URL, + DefaultModel: "claude-3-7-sonnet", + APIKeyEnv: "ANTHROPIC_TEST_KEY", + APIKeyResolver: provider.StaticAPIKeyResolver("test-key"), + GenerateMaxRetries: 0, + GenerateStartTimeout: 0, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + events := make(chan providertypes.StreamEvent, 8) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}, + }}, + }, events) + if err == nil { + t.Fatal("expected error for 429 response") + } + var providerErr *provider.ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("expected *ProviderError, got %T: %v", err, err) + } + if providerErr.Code != provider.ErrorCodeRateLimit { + t.Fatalf("expected rate_limit code, got %q", providerErr.Code) + } +} + +func TestMapAnthropicSDKErrorWithAuthError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"type":"error","error":{"type":"authentication_error","message":"Invalid key"}}`) + })) + defer server.Close() + + p, err := New(provider.RuntimeConfig{ + Driver: provider.DriverAnthropic, + BaseURL: server.URL, + DefaultModel: "claude-3-7-sonnet", + APIKeyEnv: "ANTHROPIC_TEST_KEY", + APIKeyResolver: provider.StaticAPIKeyResolver("test-key"), + GenerateMaxRetries: 0, + GenerateStartTimeout: 0, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + events := make(chan providertypes.StreamEvent, 8) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}, + }}, + }, events) + if err == nil { + t.Fatal("expected error for 401 response") + } + var providerErr *provider.ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("expected *ProviderError, got %T: %v", err, err) + } + if providerErr.Code != provider.ErrorCodeAuthFailed { + t.Fatalf("expected auth_failed code, got %q", providerErr.Code) + } +} + +func TestMapAnthropicSDKErrorServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, `{"type":"error","error":{"type":"server_error","message":"Internal"}}`) + })) + defer server.Close() + + p, err := New(provider.RuntimeConfig{ + Driver: provider.DriverAnthropic, + BaseURL: server.URL, + DefaultModel: "claude-3-7-sonnet", + APIKeyEnv: "ANTHROPIC_TEST_KEY", + APIKeyResolver: provider.StaticAPIKeyResolver("test-key"), + GenerateMaxRetries: 0, + GenerateStartTimeout: 0, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + events := make(chan providertypes.StreamEvent, 8) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}, + }}, + }, events) + if err == nil { + t.Fatal("expected error for 500 response") + } + var providerErr *provider.ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("expected *ProviderError, got %T: %v", err, err) + } + if providerErr.Code != provider.ErrorCodeServer { + t.Fatalf("expected server code, got %q", providerErr.Code) + } + if !providerErr.Retryable { + t.Fatal("expected 500 to be retryable") + } +} + +func TestMapAnthropicSDKErrorContextTooLong(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprint(w, `{"type":"error","error":{"type":"invalid_request_error","message":"max context length exceeded"}}`) + })) + defer server.Close() + + p, err := New(provider.RuntimeConfig{ + Driver: provider.DriverAnthropic, + BaseURL: server.URL, + DefaultModel: "claude-3-7-sonnet", + APIKeyEnv: "ANTHROPIC_TEST_KEY", + APIKeyResolver: provider.StaticAPIKeyResolver("test-key"), + GenerateMaxRetries: 0, + GenerateStartTimeout: 0, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + events := make(chan providertypes.StreamEvent, 8) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}, + }}, + }, events) + if err == nil { + t.Fatal("expected error for 400 response") + } + var providerErr *provider.ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("expected *ProviderError, got %T: %v", err, err) + } + if providerErr.Code != provider.ErrorCodeContextTooLong { + t.Fatalf("expected context_too_long code, got %q", providerErr.Code) + } +} + +func TestToAnthropicToolSchemaValid(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "file path", + }, + }, + "required": []any{"path"}, + } + param, err := toAnthropicToolSchema(schema) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // marshal back to verify structure + raw, _ := json.Marshal(param) + if !strings.Contains(string(raw), "path") { + t.Fatalf("expected path in marshalled schema, got %s", string(raw)) + } +} + +func TestToAnthropicToolSchemaEmptySchema(t *testing.T) { + param, err := toAnthropicToolSchema(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, _ := json.Marshal(param) + if string(raw) == "" { + t.Fatal("expected non-empty marshalled schema") + } +} + +func TestToAnthropicToolSchemaNonObjectType(t *testing.T) { + schema := map[string]any{ + "type": "string", + } + param, err := toAnthropicToolSchema(schema) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, _ := json.Marshal(param) + if !strings.Contains(string(raw), "object") { + t.Fatalf("expected schema to be normalized to object type, got %s", string(raw)) + } +} + +func TestGenerateEmptyStreamHandled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + })) + defer server.Close() + + p, err := New(provider.RuntimeConfig{ + Driver: provider.DriverAnthropic, + BaseURL: server.URL, + DefaultModel: "claude-3-7-sonnet", + APIKeyEnv: "ANTHROPIC_TEST_KEY", + APIKeyResolver: provider.StaticAPIKeyResolver("test-key"), + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + events := make(chan providertypes.StreamEvent, 8) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}, + }}, + }, events) + if err == nil { + t.Fatal("expected error for empty stream") + } + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted, got %v", err) + } +} diff --git a/internal/provider/contracts_test.go b/internal/provider/contracts_test.go new file mode 100644 index 000000000..c61ef7d0a --- /dev/null +++ b/internal/provider/contracts_test.go @@ -0,0 +1,183 @@ +package provider + +import ( + "errors" + "os" + "strings" + "testing" +) + +func TestResolveAPIKeyValueWithResolver(t *testing.T) { + cfg := RuntimeConfig{ + Name: "test-provider", + APIKeyEnv: "TEST_KEY", + APIKeyResolver: func(envName string) (string, error) { + if envName != "TEST_KEY" { + return "", errors.New("unexpected env name") + } + return "resolved-key-value", nil + }, + } + key, err := cfg.ResolveAPIKeyValue() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != "resolved-key-value" { + t.Fatalf("key = %q, want %q", key, "resolved-key-value") + } +} + +func TestResolveAPIKeyValueResolverReturnsError(t *testing.T) { + cfg := RuntimeConfig{ + Name: "test-provider", + APIKeyEnv: "TEST_KEY", + APIKeyResolver: func(envName string) (string, error) { + return "", errors.New("key not found") + }, + } + _, err := cfg.ResolveAPIKeyValue() + if err == nil || !strings.Contains(err.Error(), "key not found") { + t.Fatalf("expected resolver error, got %v", err) + } +} + +func TestResolveAPIKeyValueResolverReturnsEmptyString(t *testing.T) { + cfg := RuntimeConfig{ + Name: "test-provider", + APIKeyEnv: "TEST_KEY", + APIKeyResolver: func(envName string) (string, error) { + return " ", nil + }, + } + _, err := cfg.ResolveAPIKeyValue() + if err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("expected empty value error, got %v", err) + } +} + +func TestResolveAPIKeyValueFromEnvVar(t *testing.T) { + envName := "NEOTEST_RESOLVE_KEY_VALUE" + os.Setenv(envName, "my-secret-key") + defer os.Unsetenv(envName) + + cfg := RuntimeConfig{ + Name: "test-provider", + APIKeyEnv: envName, + } + key, err := cfg.ResolveAPIKeyValue() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != "my-secret-key" { + t.Fatalf("key = %q, want %q", key, "my-secret-key") + } +} + +func TestResolveAPIKeyValueEnvVarEmpty(t *testing.T) { + cfg := RuntimeConfig{ + Name: "test-provider", + APIKeyEnv: "NEOTEST_NONEXISTENT_VAR", + } + _, err := cfg.ResolveAPIKeyValue() + if err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("expected empty env var error, got %v", err) + } +} + +func TestResolveAPIKeyValueEmptyEnvName(t *testing.T) { + cfg := RuntimeConfig{ + Name: "test-provider", + APIKeyEnv: "", + } + _, err := cfg.ResolveAPIKeyValue() + if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { + t.Fatalf("expected empty api_key_env error, got %v", err) + } +} + +func TestResolveAPIKeyValueEmptyEnvNameNoProviderName(t *testing.T) { + cfg := RuntimeConfig{ + APIKeyEnv: "", + } + _, err := cfg.ResolveAPIKeyValue() + if err == nil { + t.Fatal("expected error for empty api_key_env without provider name") + } +} + +func TestResolveAPIKeyValueWhitespaceOnlyEnvName(t *testing.T) { + cfg := RuntimeConfig{ + Name: "test", + APIKeyEnv: " ", + } + _, err := cfg.ResolveAPIKeyValue() + if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { + t.Fatalf("expected empty api_key_env error for whitespace env name, got %v", err) + } +} + +func TestStaticAPIKeyResolver(t *testing.T) { + resolver := StaticAPIKeyResolver("test-key-123") + key, err := resolver("ANYTHING") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != "test-key-123" { + t.Fatalf("key = %q, want %q", key, "test-key-123") + } +} + +func TestStaticAPIKeyResolverEmptyKey(t *testing.T) { + resolver := StaticAPIKeyResolver("") + _, err := resolver("ANYTHING") + if err == nil || !strings.Contains(err.Error(), "static api key is empty") { + t.Fatalf("expected empty static key error, got %v", err) + } +} + +func TestStaticAPIKeyResolverWhitespaceKey(t *testing.T) { + resolver := StaticAPIKeyResolver(" ") + _, err := resolver("ANYTHING") + if err == nil || !strings.Contains(err.Error(), "static api key is empty") { + t.Fatalf("expected empty static key error for whitespace, got %v", err) + } +} + +func TestResolvedGenerateMaxRetries(t *testing.T) { + // 0 is valid (means no retries) + cfg := RuntimeConfig{GenerateMaxRetries: 0} + got := cfg.ResolvedGenerateMaxRetries() + if got != 0 { + t.Fatalf("expected 0 retries, got %d", got) + } + + // positive value is preserved + cfg2 := RuntimeConfig{GenerateMaxRetries: 5} + got2 := cfg2.ResolvedGenerateMaxRetries() + if got2 != 5 { + t.Fatalf("expected 5, got %d", got2) + } + + // negative value maps to default + cfg3 := RuntimeConfig{GenerateMaxRetries: -1} + got3 := cfg3.ResolvedGenerateMaxRetries() + if got3 != DefaultGenerateMaxRetries { + t.Fatalf("expected default retries %d, got %d", DefaultGenerateMaxRetries, got3) + } +} + +func TestResolvedGenerateStartTimeout(t *testing.T) { + cfg := RuntimeConfig{GenerateStartTimeout: 0} + got := cfg.ResolvedGenerateStartTimeout() + if got <= 0 { + t.Fatalf("expected positive timeout, got %v", got) + } +} + +func TestResolvedGenerateIdleTimeout(t *testing.T) { + cfg := RuntimeConfig{GenerateIdleTimeout: 0} + got := cfg.ResolvedGenerateIdleTimeout() + if got <= 0 { + t.Fatalf("expected positive timeout, got %v", got) + } +} diff --git a/internal/repository/git_blob_test.go b/internal/repository/git_blob_test.go new file mode 100644 index 000000000..3c4c317f1 --- /dev/null +++ b/internal/repository/git_blob_test.go @@ -0,0 +1,248 @@ +package repository + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestReadGitBlobHEAD(t *testing.T) { + // 使用项目自身的 git repo 验证 blob 读取 + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + // 读取 go.mod 的 HEAD 版本 + blobSpec := "HEAD:go.mod" + data, err := readGitBlob(context.Background(), workdir, blobSpec) + if err != nil { + t.Fatalf("readGitBlob(%q) error: %v", blobSpec, err) + } + if len(data) == 0 { + t.Fatal("blob content should not be empty") + } + if !strings.Contains(string(data), "module neo-code") { + t.Fatalf("expected module declaration in go.mod blob, got %q", string(data)[:100]) + } +} + +func TestReadGitBlobNotFound(t *testing.T) { + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + _, err = readGitBlob(context.Background(), workdir, "HEAD:nonexistent_file.txt") + if err == nil { + t.Fatal("expected error for nonexistent blob") + } +} + +func TestReadGitBlobCancelledContext(t *testing.T) { + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = readGitBlob(ctx, workdir, "HEAD:go.mod") + if err == nil { + t.Fatal("expected context cancelled error") + } +} + +func TestStatGitBlobHEAD(t *testing.T) { + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + size, err := statGitBlob(context.Background(), workdir, "HEAD:go.mod") + if err != nil { + t.Fatalf("statGitBlob error: %v", err) + } + if size <= 0 { + t.Fatalf("expected positive blob size, got %d", size) + } +} + +func TestStatGitBlobNotFound(t *testing.T) { + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + _, err = statGitBlob(context.Background(), workdir, "HEAD:nonexistent_file.txt") + if err == nil { + t.Fatal("expected error for nonexistent blob") + } +} + +func TestStatGitBlobCancelledContext(t *testing.T) { + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = statGitBlob(ctx, workdir, "HEAD:go.mod") + if err == nil { + t.Fatal("expected context cancelled error") + } +} + +func TestStatGitBlobInvalidOutput(t *testing.T) { + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + // A SHA ref like HEAD returns non-numeric output from git cat-file -s, + // causing parse error. + // Use a non-existent branch reference to trigger git error. + _, err = statGitBlob(context.Background(), workdir, "refs/heads/nonexistent:go.mod") + if err == nil { + t.Fatal("expected error for invalid reference") + } +} + +func TestBuildGitHeadBlobSpec(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"go.mod", "HEAD:go.mod"}, + {"./go.mod", "HEAD:go.mod"}, + {"internal/tools/bash/tool.go", "HEAD:internal/tools/bash/tool.go"}, + {" go.mod ", "HEAD:go.mod"}, + {"", "HEAD:"}, + } + for _, tt := range tests { + got := buildGitHeadBlobSpec(tt.path) + if got != tt.want { + t.Errorf("buildGitHeadBlobSpec(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +func TestBuildGitHeadBlobSpecWindowsBackslash(t *testing.T) { + got := buildGitHeadBlobSpec("internal\\tools\\bash\\tool.go") + if got != "HEAD:internal/tools/bash/tool.go" { + t.Fatalf("expected forward slashes, got %q", got) + } +} + +func TestResolveGitDiffPaths(t *testing.T) { + tests := []struct { + status ChangedFileStatus + path string + oldPath string + wantOriginal string + wantModified string + }{ + {StatusModified, "file.go", "", "file.go", "file.go"}, + {StatusAdded, "file.go", "", "", "file.go"}, + {StatusDeleted, "file.go", "", "file.go", ""}, + {StatusRenamed, "new.go", "old.go", "old.go", "new.go"}, + {StatusCopied, "new.go", "old.go", "old.go", "new.go"}, + {StatusUntracked, "file.go", "", "", "file.go"}, + {StatusConflicted, "file.go", "", "file.go", "file.go"}, + {ChangedFileStatus("unknown"), "file.go", "", "file.go", "file.go"}, + } + for _, tt := range tests { + entry := gitChangedEntry{Status: tt.status, Path: tt.path, OldPath: tt.oldPath} + orig, mod := resolveGitDiffPaths(entry) + if orig != tt.wantOriginal || mod != tt.wantModified { + t.Errorf("resolveGitDiffPaths(%s,%q,%q) = (%q,%q), want (%q,%q)", + tt.status, tt.path, tt.oldPath, orig, mod, tt.wantOriginal, tt.wantModified) + } + } +} + +func TestFindGitDiffEntry(t *testing.T) { + normalized := func(p string) string { + return filepathSlashClean(p) + } + entries := []gitChangedEntry{ + {Path: "pkg/a.go"}, + {Path: "pkg/b.go"}, + } + _, ok := findGitDiffEntry(entries, "pkg/c.go") + if ok { + t.Fatal("expected not found for missing entry") + } + searchPath := normalized("pkg/a.go") + entry, ok := findGitDiffEntry(entries, searchPath) + if !ok { + t.Fatalf("expected found for existing entry (searched with %q)", searchPath) + } + if entry.Path != "pkg/a.go" { + t.Fatalf("path = %q", entry.Path) + } +} + +func isGitAvailable(t *testing.T) bool { + t.Helper() + _, err := exec.LookPath("git") + return err == nil +} + +// ensureInGitRepo 验证当前目录是 git 仓库,否则跳过测试。 +func ensureInGitRepo(t *testing.T) string { + t.Helper() + gitAvailable := isGitAvailable(t) + if !gitAvailable { + t.Skip("git not available") + } + workdir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if _, err := os.Stat(filepath.Join(workdir, ".git")); err != nil { + t.Skip("not in a git repo") + } + return workdir +} diff --git a/internal/repository/git_diff.go b/internal/repository/git_diff.go index 2db6bdcc8..aeeada83d 100644 --- a/internal/repository/git_diff.go +++ b/internal/repository/git_diff.go @@ -195,7 +195,8 @@ func isDirectoryGitDiffPath(workdir string, relativePath string) bool { // buildGitHeadBlobSpec 构造 HEAD blob 读取使用的对象说明。 func buildGitHeadBlobSpec(path string) string { - normalized := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(path)), "./") + trimmed := strings.TrimSpace(path) + normalized := strings.TrimPrefix(filepath.ToSlash(strings.ReplaceAll(trimmed, "\\", "/")), "./") return "HEAD:" + normalized } diff --git a/internal/runtime/accept_checks_test.go b/internal/runtime/accept_checks_test.go deleted file mode 100644 index 2758e1be7..000000000 --- a/internal/runtime/accept_checks_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package runtime - -import agentsession "neo-code/internal/session" - -func acceptText(items ...string) agentsession.AcceptChecks { - out := make(agentsession.AcceptChecks, 0, len(items)) - for _, item := range items { - out = append(out, agentsession.AcceptCheck{Kind: agentsession.AcceptCheckOutputOnly, Target: item}) - } - return out -} diff --git a/internal/runtime/acceptance_continue.go b/internal/runtime/acceptance_continue.go new file mode 100644 index 000000000..251e6ce53 --- /dev/null +++ b/internal/runtime/acceptance_continue.go @@ -0,0 +1,109 @@ +package runtime + +import ( + "context" + "strings" + + "neo-code/internal/runtime/acceptgate" + "neo-code/internal/runtime/controlplane" +) + +type hookToolSummaryItem struct { + Name string `json:"name"` + IsError bool `json:"is_error,omitempty"` +} + +// shouldRunAcceptGateHook 判断当前 run 是否需要进入用户验收 hook。 +func (s *runState) shouldRunAcceptGateHook() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.hasRunWorkspaceWrite +} + +// recordRecentToolSummary 记录最近一批工具摘要,供 accept_gate hook 获取最小上下文。 +func (s *runState) recordRecentToolSummary(summary toolExecutionSummary) { + if s == nil || len(summary.Results) == 0 { + return + } + items := make([]hookToolSummaryItem, 0, len(summary.Results)) + for _, result := range summary.Results { + name := strings.TrimSpace(result.Name) + if name == "" { + continue + } + items = append(items, hookToolSummaryItem{Name: name, IsError: result.IsError}) + } + if len(items) == 0 { + return + } + s.mu.Lock() + s.recentToolSummary = append(s.recentToolSummary, items...) + if len(s.recentToolSummary) > 20 { + s.recentToolSummary = append([]hookToolSummaryItem(nil), s.recentToolSummary[len(s.recentToolSummary)-20:]...) + } + s.mu.Unlock() +} + +// buildAcceptGateHookMetadata 组装用户验收 hook 可见的最小运行态摘要。 +func buildAcceptGateHookMetadata(state *runState, assistantText string) map[string]any { + if state == nil { + return nil + } + state.mu.Lock() + defer state.mu.Unlock() + todoSnapshot := buildTodoSnapshotFromItems(cloneTodosForPersistence(state.session.Todos)) + return map[string]any{ + "workdir": strings.TrimSpace(state.effectiveWorkdir), + "workspace_changed": state.hasRunWorkspaceWrite, + "assistant_text_empty": strings.TrimSpace(assistantText) == "", + "todo_summary": todoSnapshot.Summary, + "recent_tool_summary": append([]hookToolSummaryItem(nil), state.recentToolSummary...), + } +} + +// continuedAcceptGateReport 将 hook 阻断映射为统一的 Continue 判定。 +func continuedAcceptGateReport(reason string) acceptgate.Report { + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "accept gate hook blocked completion" + } + return acceptgate.Report{ + Outcome: acceptgate.OutcomeContinue, + StopReason: controlplane.StopReasonAcceptContinue, + Summary: reason, + ContinueHint: reason, + } +} + +// handleAcceptanceContinue 处理可恢复验收结果;返回 true 表示应继续下一轮。 +func (s *Service) handleAcceptanceContinue(ctx context.Context, state *runState, report acceptgate.Report) bool { + if state == nil { + return false + } + hint := strings.TrimSpace(report.ContinueHint) + if hint == "" { + hint = strings.TrimSpace(report.Summary) + } + state.mu.Lock() + state.acceptanceContinueCount++ + count := state.acceptanceContinueCount + if count <= maxAcceptanceContinues { + state.pendingSystemReminder = hint + } + state.mu.Unlock() + if count <= maxAcceptanceContinues { + return true + } + state.markTerminalDecision( + controlplane.TerminalStatusIncomplete, + controlplane.StopReasonAcceptContinueExhausted, + "acceptance continue limit reached", + ) + s.emitRunScopedOptional(EventVerificationFailed, state, VerificationFailedPayload{ + StopReason: controlplane.StopReasonAcceptContinueExhausted, + }) + return false +} diff --git a/internal/runtime/acceptance_continue_test.go b/internal/runtime/acceptance_continue_test.go new file mode 100644 index 000000000..802d2ea24 --- /dev/null +++ b/internal/runtime/acceptance_continue_test.go @@ -0,0 +1,159 @@ +package runtime + +import ( + "context" + "testing" + "time" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" + runtimehooks "neo-code/internal/runtime/hooks" + "neo-code/internal/tools" +) + +func TestRunAcceptGateHookBlocksBeforeSystemPrecheckAndContinues(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{{ + ID: "call-write", + Name: "filesystem_write_file", + Arguments: `{"path":"app.go","content":"package main"}`, + }}, + }, + FinishReason: "tool_calls", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("first finish")}, + }, + FinishReason: "stop", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("fixed finish")}, + }, + FinishReason: "stop", + }, + }, + } + toolManager := &stubToolManager{ + result: tools.ToolResult{ + Name: "filesystem_write_file", + Content: "ok", + Facts: tools.ToolExecutionFacts{WorkspaceWrite: true}, + Metadata: map[string]any{ + "path": "app.go", + "tool_diff": "+package main", + "tool_diff_new": true, + }, + }, + } + service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + + blockCount := 0 + registry := runtimehooks.NewRegistry() + if err := registry.Register(runtimehooks.HookSpec{ + ID: "accept-gate-review", + Point: runtimehooks.HookPointAcceptGate, + Handler: func(context.Context, runtimehooks.HookContext) runtimehooks.HookResult { + blockCount++ + if blockCount == 1 { + return runtimehooks.HookResult{Status: runtimehooks.HookResultBlock, Message: "tests failed"} + } + return runtimehooks.HookResult{Status: runtimehooks.HookResultPass} + }, + }); err != nil { + t.Fatalf("register accept_gate hook: %v", err) + } + service.SetHookExecutor(runtimehooks.NewExecutor(registry, newHookRuntimeEventEmitter(service), time.Second)) + + if err := service.Run(context.Background(), UserInput{ + RunID: "run-accept-gate-continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("update code")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + if scripted.callCount != 3 { + t.Fatalf("provider call count = %d, want 3", scripted.callCount) + } + if blockCount != 2 { + t.Fatalf("accept_gate hook count = %d, want 2", blockCount) + } + + events := collectRuntimeEvents(service.Events()) + blocked := eventIndex(events, EventHookBlocked) + accepted := lastEventIndex(events, EventAcceptanceDecided) + if blocked < 0 || accepted < 0 || blocked > accepted { + t.Fatalf("expected hook block before final acceptance decision, events=%v", eventTypes(events)) + } +} + +func TestRunEmptyOutputContinuesUntilExhausted(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + scripted := &scriptedProvider{ + estimateFn: func(context.Context, providertypes.GenerateRequest) (providertypes.BudgetEstimate, error) { + return providertypes.BudgetEstimate{EstimatedInputTokens: 1, EstimateSource: provider.EstimateSourceLocal}, nil + }, + responses: []scriptedResponse{ + {Message: providertypes.Message{Role: providertypes.RoleAssistant}, FinishReason: "stop"}, + {Message: providertypes.Message{Role: providertypes.RoleAssistant}, FinishReason: "stop"}, + {Message: providertypes.Message{Role: providertypes.RoleAssistant}, FinishReason: "stop"}, + {Message: providertypes.Message{Role: providertypes.RoleAssistant}, FinishReason: "stop"}, + }, + } + service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + + if err := service.Run(context.Background(), UserInput{ + RunID: "run-empty-output-continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue until exhausted")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + if scripted.callCount != 4 { + t.Fatalf("provider call count = %d, want 4", scripted.callCount) + } + events := collectRuntimeEvents(service.Events()) + verificationFailed := lastEventOfType(events, EventVerificationFailed) + payload, ok := verificationFailed.Payload.(VerificationFailedPayload) + if !ok || payload.StopReason != controlplane.StopReasonAcceptContinueExhausted { + t.Fatalf("verification failed payload = %+v, want accept_continue_exhausted", verificationFailed.Payload) + } +} + +func eventTypes(events []RuntimeEvent) []EventType { + out := make([]EventType, 0, len(events)) + for _, event := range events { + out = append(out, event.Type) + } + return out +} + +func lastEventIndex(events []RuntimeEvent, eventType EventType) int { + for index := len(events) - 1; index >= 0; index-- { + if events[index].Type == eventType { + return index + } + } + return -1 +} + +func lastEventOfType(events []RuntimeEvent, eventType EventType) RuntimeEvent { + index := lastEventIndex(events, eventType) + if index < 0 { + return RuntimeEvent{} + } + return events[index] +} diff --git a/internal/runtime/acceptgate/checks.go b/internal/runtime/acceptgate/checks.go index b3f390022..5511e4a49 100644 --- a/internal/runtime/acceptgate/checks.go +++ b/internal/runtime/acceptgate/checks.go @@ -1,12 +1,18 @@ package acceptgate import ( - "path/filepath" "strings" agentsession "neo-code/internal/session" ) +func checkOutputOnly(lastAssistantText string) CheckResult { + if strings.TrimSpace(lastAssistantText) != "" { + return CheckResult{Passed: true, Name: "output_only"} + } + return CheckResult{Passed: false, Name: "output_only", Reason: "assistant output is empty"} +} + func checkRequiredTodoFailures(todos []agentsession.TodoItem) CheckResult { for _, todo := range todos { if !todo.RequiredValue() { @@ -38,211 +44,3 @@ func checkRequiredTodoConvergence(todos []agentsession.TodoItem) CheckResult { } return CheckResult{Passed: true, Name: "required_todo_convergence"} } - -func evaluateAcceptCheck(input Input, check agentsession.AcceptCheck) CheckResult { - check.Kind = strings.TrimSpace(check.Kind) - check.Target = strings.TrimSpace(check.Target) - switch check.Kind { - case agentsession.AcceptCheckOutputOnly: - return checkOutputOnly(input, check) - case agentsession.AcceptCheckWorkspaceChange: - return checkWorkspaceChange(input, check) - case agentsession.AcceptCheckCommandSuccess: - return checkCommandSuccess(input, check) - case agentsession.AcceptCheckFileExists: - return checkFileExists(input, check) - case agentsession.AcceptCheckContentContains: - return checkContentContains(input, check) - case agentsession.AcceptCheckToolFact: - return checkToolFact(input, check) - default: - return CheckResult{ - Passed: false, - Name: checkName(check), - Kind: check.Kind, - Target: check.Target, - Reason: "unknown required accept check kind", - } - } -} - -func checkOutputOnly(input Input, check agentsession.AcceptCheck) CheckResult { - if strings.TrimSpace(input.LastAssistantText) != "" { - return pass(check) - } - return fail(check, "assistant output is empty") -} - -func checkWorkspaceChange(input Input, check agentsession.AcceptCheck) CheckResult { - if len(input.Facts.Files.Written) > 0 { - return pass(check) - } - for _, item := range input.Facts.Files.Exists { - switch strings.TrimSpace(item.Source) { - case "filesystem_write_file", "filesystem_edit", "bash", "workspace_write": - return pass(check) - } - } - return fail(check, "missing workspace change evidence") -} - -func checkCommandSuccess(input Input, check agentsession.AcceptCheck) CheckResult { - target := normalizeCommand(check.Target) - if target == "" { - return fail(check, "command target is empty") - } - for _, fact := range input.Facts.Commands.Executed { - if !fact.Succeeded { - continue - } - if commandMatches(normalizeCommand(fact.Command), target, check.Match) { - return pass(check) - } - } - return fail(check, "missing successful command evidence") -} - -func checkFileExists(input Input, check agentsession.AcceptCheck) CheckResult { - target := normalizePath(check.Target) - if target == "" { - return fail(check, "file target is empty") - } - for _, fact := range input.Facts.Files.Exists { - if normalizePath(fact.Path) == target { - return pass(check) - } - } - for _, fact := range input.Facts.Files.Written { - if normalizePath(fact.Path) == target { - return pass(check) - } - } - return fail(check, "missing file existence evidence") -} - -func checkContentContains(input Input, check agentsession.AcceptCheck) CheckResult { - target := normalizePath(check.Target) - if target == "" { - return fail(check, "content target is empty") - } - for _, fact := range input.Facts.Files.ContentMatch { - if normalizePath(fact.Path) != target || !fact.VerificationPassed { - continue - } - if expected := strings.TrimSpace(check.Params["contains"]); expected != "" { - if !containsString(fact.ExpectedContains, expected) { - continue - } - } - return pass(check) - } - return fail(check, "missing content match evidence") -} - -func checkToolFact(input Input, check agentsession.AcceptCheck) CheckResult { - scope := strings.TrimSpace(firstNonEmpty(check.Params["scope"], check.Target)) - tool := strings.TrimSpace(check.Params["tool"]) - for _, fact := range input.Facts.Verification.Passed { - if tool != "" && !strings.EqualFold(strings.TrimSpace(fact.Tool), tool) { - continue - } - if scope != "" && strings.TrimSpace(fact.Scope) != scope { - continue - } - return pass(check) - } - return fail(check, "missing tool verification fact") -} - -func pass(check agentsession.AcceptCheck) CheckResult { - return CheckResult{Passed: true, Name: checkName(check), Kind: check.Kind, Target: check.Target} -} - -func fail(check agentsession.AcceptCheck, reason string) CheckResult { - return CheckResult{Passed: false, Name: checkName(check), Kind: check.Kind, Target: check.Target, Reason: reason} -} - -func checkName(check agentsession.AcceptCheck) string { - if id := strings.TrimSpace(check.ID); id != "" { - return id - } - if kind := strings.TrimSpace(check.Kind); kind != "" { - return kind - } - return "accept_check" -} - -func commandMatches(actual, target, mode string) bool { - switch strings.TrimSpace(strings.ToLower(mode)) { - case "exact": - return actual == target - case "prefix": - return strings.HasPrefix(actual, target) - case "contains", "normalized_contains", "": - return actual == target || strings.Contains(actual, target) - default: - return actual == target || strings.Contains(actual, target) - } -} - -func normalizeCommand(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - fields := strings.Fields(value) - out := make([]string, 0, len(fields)) - for _, field := range fields { - if isEnvVarAssignment(field) { - continue - } - if strings.HasPrefix(strings.ToLower(field), "$env:") { - continue - } - out = append(out, field) - } - return strings.ToLower(strings.Join(out, " ")) -} - -// isEnvVarAssignment 识别裸环境变量赋值,避免把 CLI 的 -flag=value 当作环境变量剥离。 -func isEnvVarAssignment(field string) bool { - field = strings.TrimSpace(field) - if !strings.Contains(field, "=") { - return false - } - if strings.HasPrefix(field, "-") { - return false - } - if strings.Contains(field, "/") || strings.Contains(field, "\\") { - return false - } - return true -} - -func normalizePath(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - cleaned := filepath.ToSlash(filepath.Clean(value)) - cleaned = strings.TrimPrefix(cleaned, "./") - return strings.ToLower(cleaned) -} - -func containsString(values []string, target string) bool { - for _, value := range values { - if strings.TrimSpace(value) == target { - return true - } - } - return false -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} diff --git a/internal/runtime/acceptgate/checks_test.go b/internal/runtime/acceptgate/checks_test.go index 323337562..bb170fd95 100644 --- a/internal/runtime/acceptgate/checks_test.go +++ b/internal/runtime/acceptgate/checks_test.go @@ -1,56 +1,30 @@ package acceptgate import ( - "context" "testing" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) -func TestNormalizeCommandKeepsCLIFlags(t *testing.T) { +func TestCheckOutputOnly(t *testing.T) { t.Parallel() - got := normalizeCommand("go test ./... -run=TestFoo --filter=a=b -count=1") - want := "go test ./... -run=testfoo --filter=a=b -count=1" - if got != want { - t.Fatalf("normalizeCommand() = %q, want %q", got, want) + if got := checkOutputOnly("done"); !got.Passed { + t.Fatalf("checkOutputOnly(done) = %+v, want pass", got) } -} - -func TestNormalizeCommandStripsEnvVars(t *testing.T) { - t.Parallel() - - got := normalizeCommand("CGO_ENABLED=0 GOFLAGS=-count=1 go test ./...") - if got != "go test ./..." { - t.Fatalf("normalizeCommand() = %q, want %q", got, "go test ./...") - } -} - -func TestNormalizeCommandKeepsPathAssignments(t *testing.T) { - t.Parallel() - - got := normalizeCommand("PKG=./cmd/... go test") - if got != "pkg=./cmd/... go test" { - t.Fatalf("normalizeCommand() = %q, want %q", got, "pkg=./cmd/... go test") + if got := checkOutputOnly(" "); got.Passed { + t.Fatalf("checkOutputOnly(blank) = %+v, want fail", got) } } -func TestEvaluateCommandSuccessKeepsFlagSpecificity(t *testing.T) { +func TestCheckRequiredTodoConvergence(t *testing.T) { t.Parallel() - report := Evaluate(context.Background(), Input{ - PlanVerify: agentsession.AcceptChecks{ - {Kind: agentsession.AcceptCheckCommandSuccess, Target: "go test ./... -run=TestFoo"}, - }, - Facts: runtimefacts.RuntimeFacts{ - Commands: runtimefacts.CommandFacts{Executed: []runtimefacts.CommandFact{ - {Tool: "bash", Command: "go test ./...", Succeeded: true}, - }}, - }, - LastAssistantText: "done", + required := true + result := checkRequiredTodoConvergence([]agentsession.TodoItem{ + {ID: "todo-1", Required: &required, Status: agentsession.TodoStatusPending}, }) - if report.Outcome != OutcomeFailed { - t.Fatalf("report = %+v, want failed because broad command must not satisfy -run-specific check", report) + if result.Passed { + t.Fatalf("result = %+v, want failed", result) } } diff --git a/internal/runtime/acceptgate/gate.go b/internal/runtime/acceptgate/gate.go index b35d41abd..e7d368c1d 100644 --- a/internal/runtime/acceptgate/gate.go +++ b/internal/runtime/acceptgate/gate.go @@ -6,46 +6,44 @@ import ( "strings" "neo-code/internal/runtime/controlplane" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) -// Outcome 表示 Accept Gate 的二元终态结果。 +// Outcome 表示 Accept Gate 的系统预检结果。 type Outcome string const ( - // OutcomeAccepted 表示所有必需验收项均已满足。 + // OutcomeAccepted 表示系统预检已通过。 OutcomeAccepted Outcome = "accepted" - // OutcomeFailed 表示至少一个必需验收项缺少运行期证据或状态未收敛。 + // OutcomeContinue 表示存在可恢复问题,应提示模型继续工作。 + OutcomeContinue Outcome = "continued" + // OutcomeFailed 表示存在不可恢复问题,应终止本轮。 OutcomeFailed Outcome = "failed" ) -// Input 汇总最终验收所需的运行期事实和 plan 状态。 +// Input 汇总系统预检所需的最小运行态。 type Input struct { - PlanVerify agentsession.AcceptChecks - Facts runtimefacts.RuntimeFacts Todos []agentsession.TodoItem LastAssistantText string } -// CheckResult 描述单个验收项的判定结果。 +// CheckResult 描述单个系统预检项的判定结果。 type CheckResult struct { Passed bool `json:"passed"` Name string `json:"name"` - Kind string `json:"kind,omitempty"` - Target string `json:"target,omitempty"` Reason string `json:"reason,omitempty"` } // Report 描述 Accept Gate 的完整判定报告。 type Report struct { - Outcome Outcome `json:"status"` - StopReason controlplane.StopReason `json:"stop_reason,omitempty"` - Summary string `json:"summary,omitempty"` - Results []CheckResult `json:"results,omitempty"` + Outcome Outcome `json:"status"` + StopReason controlplane.StopReason `json:"stop_reason,omitempty"` + Summary string `json:"summary,omitempty"` + ContinueHint string `json:"continue_hint,omitempty"` + Results []CheckResult `json:"results,omitempty"` } -// Evaluate 按固定顺序检查 plan-owned todo 与 Plan.Verify 运行期证据。 +// Evaluate 执行收尾前的系统预检,只处理框架级状态,不再承担内容正确性验证。 func Evaluate(ctx context.Context, input Input) Report { if err := ctx.Err(); err != nil { return Report{ @@ -59,27 +57,14 @@ func Evaluate(ctx context.Context, input Input) Report { Outcome: OutcomeAccepted, StopReason: controlplane.StopReasonAccepted, } - + report.add(checkOutputOnly(input.LastAssistantText)) report.add(checkRequiredTodoFailures(input.Todos)) report.add(checkRequiredTodoConvergence(input.Todos)) - - checks := input.PlanVerify.Normalize() - if len(checks) == 0 { - checks = agentsession.AcceptChecks{{Kind: agentsession.AcceptCheckOutputOnly}} - } - for _, check := range checks { - result := evaluateAcceptCheck(input, check) - if !check.RequiredValue() { - report.addOptional(result) - continue - } - report.add(result) - } report.finalize() return report } -// add 记录必需验收项结果,并在失败时更新终态原因。 +// add 记录系统预检结果,并按可恢复性更新终态。 func (r *Report) add(result CheckResult) { if strings.TrimSpace(result.Name) == "" { return @@ -88,39 +73,32 @@ func (r *Report) add(result CheckResult) { if result.Passed { return } - r.Outcome = OutcomeFailed switch result.Name { case "required_todo_failed": + r.Outcome = OutcomeFailed r.StopReason = controlplane.StopReasonRequiredTodoFailed - case "required_todo_convergence": - if r.StopReason != controlplane.StopReasonRequiredTodoFailed { - r.StopReason = controlplane.StopReasonTodoNotConverged + case "output_only": + if r.Outcome != OutcomeFailed { + r.Outcome = OutcomeContinue + r.StopReason = controlplane.StopReasonAcceptContinue + r.ContinueHint = "你刚才没有给出可见回复,请继续完成任务并给出明确结果。" } - default: - if r.StopReason == "" || r.StopReason == controlplane.StopReasonAccepted { - r.StopReason = controlplane.StopReasonAcceptCheckFailed + case "required_todo_convergence": + if r.Outcome != OutcomeFailed { + r.Outcome = OutcomeContinue + r.StopReason = controlplane.StopReasonAcceptContinue + r.ContinueHint = "仍有 required todo 未完成,请继续处理后再结束。" } } } -// addOptional 保留可选验收项结果,但不让可选失败改变终态。 -func (r *Report) addOptional(result CheckResult) { - if strings.TrimSpace(result.Name) == "" { - return - } - r.Results = append(r.Results, result) -} - -// finalize 汇总逐项失败原因,形成对上层展示稳定的终态摘要。 +// finalize 汇总失败原因,形成对上层展示稳定的判定摘要。 func (r *Report) finalize() { if r.Outcome == OutcomeAccepted { r.StopReason = controlplane.StopReasonAccepted - r.Summary = "acceptance checks passed" + r.Summary = "acceptance prechecks passed" return } - if r.StopReason == "" || r.StopReason == controlplane.StopReasonAccepted { - r.StopReason = controlplane.StopReasonAcceptCheckFailed - } failures := make([]string, 0, len(r.Results)) for _, result := range r.Results { if result.Passed { diff --git a/internal/runtime/acceptgate/gate_test.go b/internal/runtime/acceptgate/gate_test.go index 15aefae09..7a3461336 100644 --- a/internal/runtime/acceptgate/gate_test.go +++ b/internal/runtime/acceptgate/gate_test.go @@ -2,163 +2,38 @@ package acceptgate import ( "context" - "encoding/json" "testing" "neo-code/internal/runtime/controlplane" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) -func TestEvaluateFallbackOutputOnly(t *testing.T) { +func TestEvaluateAcceptedWhenOutputAndTodosConverged(t *testing.T) { t.Parallel() report := Evaluate(context.Background(), Input{LastAssistantText: "done"}) if report.Outcome != OutcomeAccepted || report.StopReason != controlplane.StopReasonAccepted { t.Fatalf("report = %+v, want accepted", report) } - - report = Evaluate(context.Background(), Input{}) - if report.Outcome != OutcomeFailed || report.StopReason != controlplane.StopReasonAcceptCheckFailed { - t.Fatalf("report = %+v, want accept_check_failed", report) - } -} - -func TestEvaluateCommandSuccess(t *testing.T) { - t.Parallel() - - input := Input{ - PlanVerify: agentsession.AcceptChecks{{Kind: agentsession.AcceptCheckCommandSuccess, Target: "go test ./..."}}, - Facts: runtimefacts.RuntimeFacts{ - Commands: runtimefacts.CommandFacts{Executed: []runtimefacts.CommandFact{ - {Tool: "bash", Command: "GOFLAGS=-count=1 go test ./...", Succeeded: true}, - }}, - }, - LastAssistantText: "done", - } - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeAccepted { - t.Fatalf("report = %+v, want accepted", report) - } - - input.Facts.Commands.Executed[0].Succeeded = false - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeFailed { - t.Fatalf("report = %+v, want failed", report) - } -} - -func TestEvaluateWorkspaceChangeUsesRuntimeFactsOnly(t *testing.T) { - t.Parallel() - - input := Input{ - PlanVerify: agentsession.AcceptChecks{{Kind: agentsession.AcceptCheckWorkspaceChange}}, - Facts: runtimefacts.RuntimeFacts{ - Files: runtimefacts.FileFacts{Written: []runtimefacts.FileWriteFact{{Path: "internal/foo.go"}}}, - }, - LastAssistantText: "done", - } - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeAccepted { - t.Fatalf("written fact report = %+v, want accepted", report) - } - - input.Facts.Files.Written = nil - input.Facts.Files.Exists = []runtimefacts.FileExistFact{{Path: "internal/foo.go", Source: "filesystem_write_file"}} - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeAccepted { - t.Fatalf("write-source exists report = %+v, want accepted", report) - } - - input.Facts.Files.Exists = []runtimefacts.FileExistFact{{Path: "internal/foo.go", Source: "filesystem_read_file"}} - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeFailed { - t.Fatalf("read-only fact report = %+v, want failed", report) - } - - input.Facts.Files.Exists = []runtimefacts.FileExistFact{{Path: "internal/foo.go", Source: "filesystem_write_file_noop"}} - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeFailed { - t.Fatalf("noop write fact report = %+v, want failed", report) - } } -func TestEvaluateFileAndContentFacts(t *testing.T) { +func TestEvaluateContinueWhenOutputEmpty(t *testing.T) { t.Parallel() - input := Input{ - PlanVerify: agentsession.AcceptChecks{ - {Kind: agentsession.AcceptCheckFileExists, Target: "./README.md"}, - {Kind: agentsession.AcceptCheckContentContains, Target: "README.md", Params: map[string]string{"contains": "NeoCode"}}, - }, - Facts: runtimefacts.RuntimeFacts{ - Files: runtimefacts.FileFacts{ - Exists: []runtimefacts.FileExistFact{{Path: "README.md", Source: "filesystem_read_file"}}, - ContentMatch: []runtimefacts.FileContentMatchFact{{ - Path: "README.md", - ExpectedContains: []string{"NeoCode"}, - VerificationPassed: true, - }}, - }, - }, - LastAssistantText: "done", - } - if report := Evaluate(context.Background(), input); report.Outcome != OutcomeAccepted { - t.Fatalf("report = %+v, want accepted", report) - } - - input.Facts.Files.ContentMatch[0].VerificationPassed = false - report := Evaluate(context.Background(), input) - if report.Outcome != OutcomeFailed || len(report.Results) != 4 { - t.Fatalf("report = %+v, want failed with all results", report) - } -} - -func TestEvaluateToolFactAndUnknownKind(t *testing.T) { - t.Parallel() - - input := Input{ - PlanVerify: agentsession.AcceptChecks{ - {Kind: agentsession.AcceptCheckToolFact, Params: map[string]string{"tool": "bash", "scope": "test"}}, - {Kind: "future_check"}, - }, - Facts: runtimefacts.RuntimeFacts{ - Verification: runtimefacts.VerificationFacts{ - Passed: []runtimefacts.VerificationFact{{Tool: "bash", Scope: "test"}}, - }, - }, - LastAssistantText: "done", - } - report := Evaluate(context.Background(), input) - if report.Outcome != OutcomeFailed || report.StopReason != controlplane.StopReasonAcceptCheckFailed { - t.Fatalf("report = %+v, want unknown kind failure", report) - } - if report.Results[len(report.Results)-1].Reason != "unknown required accept check kind" { - t.Fatalf("last result = %+v, want unknown kind reason", report.Results[len(report.Results)-1]) - } -} - -func TestEvaluateOptionalUnknownKindDoesNotFail(t *testing.T) { - t.Parallel() - - optional := false - var checks agentsession.AcceptChecks - if err := json.Unmarshal([]byte(`[{"kind":"output_only"},{"kind":"future_check","required":false}]`), &checks); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - checks = append(checks, agentsession.AcceptCheck{Kind: "go_literal_optional", Required: &optional}) - report := Evaluate(context.Background(), Input{ - PlanVerify: checks, - LastAssistantText: "done", - }) - if report.Outcome != OutcomeAccepted { - t.Fatalf("report = %+v, want accepted", report) + report := Evaluate(context.Background(), Input{}) + if report.Outcome != OutcomeContinue || report.StopReason != controlplane.StopReasonAcceptContinue { + t.Fatalf("report = %+v, want continue", report) } - if len(report.Results) != 5 { - t.Fatalf("results len = %d, want 5", len(report.Results)) + if report.ContinueHint == "" { + t.Fatalf("continue hint should not be empty: %+v", report) } } -func TestEvaluateTodoPriority(t *testing.T) { +func TestEvaluateTodoOutcomes(t *testing.T) { t.Parallel() required := true input := Input{ - PlanVerify: agentsession.AcceptChecks{{Kind: agentsession.AcceptCheckOutputOnly}}, LastAssistantText: "done", Todos: []agentsession.TodoItem{ {ID: "todo-1", Status: agentsession.TodoStatusFailed, Required: &required}, @@ -171,7 +46,7 @@ func TestEvaluateTodoPriority(t *testing.T) { input.Todos[0].Status = agentsession.TodoStatusPending report = Evaluate(context.Background(), input) - if report.Outcome != OutcomeFailed || report.StopReason != controlplane.StopReasonTodoNotConverged { - t.Fatalf("pending todo report = %+v, want todo_not_converged", report) + if report.Outcome != OutcomeContinue || report.StopReason != controlplane.StopReasonAcceptContinue { + t.Fatalf("pending todo report = %+v, want continue", report) } } diff --git a/internal/runtime/acceptgate_runtime.go b/internal/runtime/acceptgate_runtime.go index 8c6b6f7ef..cc7781ec8 100644 --- a/internal/runtime/acceptgate_runtime.go +++ b/internal/runtime/acceptgate_runtime.go @@ -7,35 +7,20 @@ import ( "neo-code/internal/partsrender" providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/acceptgate" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) -// evaluateAcceptGate 从运行态提取事实快照,并执行最终 Accept Gate。 +// evaluateAcceptGate 从运行态提取系统预检所需的最小状态,并执行最终 Accept Gate。 func (s *Service) evaluateAcceptGate(ctx context.Context, state *runState, assistantMessage providertypes.Message) acceptgate.Report { if state == nil { return acceptgate.Evaluate(ctx, acceptgate.Input{}) } state.mu.Lock() - var planVerify agentsession.AcceptChecks - var currentPlan *agentsession.PlanArtifact - if state.session.CurrentPlan != nil { - currentPlan = state.session.CurrentPlan.Clone() - planVerify = currentPlan.Summary.Verify.Clone() - if len(planVerify) == 0 { - planVerify = currentPlan.Spec.Verify.Clone() - } - } + currentPlan := state.session.CurrentPlan.Clone() todos := selectPlanOwnedTodos(currentPlan, cloneTodosForPersistence(state.session.Todos)) - factsSnapshot := runtimefacts.RuntimeFacts{} - if state.factsCollector != nil { - factsSnapshot = state.factsCollector.Snapshot() - } state.mu.Unlock() return acceptgate.Evaluate(ctx, acceptgate.Input{ - PlanVerify: planVerify, - Facts: factsSnapshot, Todos: todos, LastAssistantText: strings.TrimSpace(partsrender.RenderDisplayParts(assistantMessage.Parts)), }) @@ -85,14 +70,11 @@ func isPostPlanRequiredTodo(plan *agentsession.PlanArtifact, todo agentsession.T // emitAcceptGateReport 将 Accept Gate 报告发布为统一 acceptance_decided 事件。 func (s *Service) emitAcceptGateReport(state *runState, report acceptgate.Report) { - status := string(acceptgate.OutcomeFailed) - if report.Outcome == acceptgate.OutcomeAccepted { - status = string(acceptgate.OutcomeAccepted) - } s.emitRunScopedOptional(EventAcceptanceDecided, state, AcceptanceDecidedPayload{ - Status: status, - StopReason: report.StopReason, - Summary: report.Summary, - Results: append([]acceptgate.CheckResult(nil), report.Results...), + Status: string(report.Outcome), + StopReason: report.StopReason, + Summary: report.Summary, + ContinueHint: report.ContinueHint, + Results: append([]acceptgate.CheckResult(nil), report.Results...), }) } diff --git a/internal/runtime/ask_test.go b/internal/runtime/ask_test.go new file mode 100644 index 000000000..b1ecac3f2 --- /dev/null +++ b/internal/runtime/ask_test.go @@ -0,0 +1,914 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "neo-code/internal/config" + agentcontext "neo-code/internal/context" + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" + "neo-code/internal/skills" +) + +// ---- stub for skills.Registry used in buildAskSystemPrompt tests ---- + +type stubAskSkillsRegistry struct { + skills map[string]stubAskSkill + getErr error +} + +type stubAskSkill struct { + descriptor skills.Descriptor + instruction string +} + +func (r *stubAskSkillsRegistry) List(_ context.Context, _ skills.ListInput) ([]skills.Descriptor, error) { + return nil, nil +} +func (r *stubAskSkillsRegistry) Get(_ context.Context, id string) (skills.Descriptor, skills.Content, error) { + if r.getErr != nil { + return skills.Descriptor{}, skills.Content{}, r.getErr + } + sk, ok := r.skills[strings.ToLower(strings.TrimSpace(id))] + if !ok { + return skills.Descriptor{}, skills.Content{}, errors.New("skill not found") + } + return sk.descriptor, skills.Content{Instruction: sk.instruction}, nil +} +func (r *stubAskSkillsRegistry) Refresh(_ context.Context) error { return nil } +func (r *stubAskSkillsRegistry) Reload(_ context.Context) error { return nil } +func (r *stubAskSkillsRegistry) Count() int { return 0 } + +func newStubAskSkillsRegistry() *stubAskSkillsRegistry { + return &stubAskSkillsRegistry{skills: make(map[string]stubAskSkill)} +} + +func (r *stubAskSkillsRegistry) addSkill(id, instruction string) { + r.skills[strings.ToLower(strings.TrimSpace(id))] = stubAskSkill{ + descriptor: skills.Descriptor{ID: strings.TrimSpace(id)}, + instruction: instruction, + } +} + +// ---- Ask nil / cancelled / empty input ---- + +func TestAskNilService(t *testing.T) { + var s *Service + err := s.Ask(context.Background(), AskInput{UserQuery: "test"}) + if err == nil || !strings.Contains(err.Error(), "service is nil") { + t.Fatalf("expected nil service error, got %v", err) + } +} + +func TestAskCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &Service{} + err := s.Ask(ctx, AskInput{UserQuery: "test"}) + if err == nil { + t.Fatal("expected context cancelled error") + } +} + +func TestAskEmptyUserQuery(t *testing.T) { + s := &Service{} + err := s.Ask(context.Background(), AskInput{UserQuery: " "}) + if err == nil || !strings.Contains(err.Error(), "user query is empty") { + t.Fatalf("expected empty query error, got %v", err) + } +} + +// ---- DeleteAskSession ---- + +func TestDeleteAskSessionNilService(t *testing.T) { + var s *Service + _, err := s.DeleteAskSession(context.Background(), DeleteAskSessionInput{SessionID: "s1"}) + if err == nil || !strings.Contains(err.Error(), "service is nil") { + t.Fatalf("expected nil service error, got %v", err) + } +} + +func TestDeleteAskSessionEmptyID(t *testing.T) { + s := &Service{} + ok, err := s.DeleteAskSession(context.Background(), DeleteAskSessionInput{SessionID: ""}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false for empty session ID") + } +} + +func TestDeleteAskSessionNoStore(t *testing.T) { + s := &Service{} + ok, err := s.DeleteAskSession(context.Background(), DeleteAskSessionInput{SessionID: "s1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false when store is nil") + } +} + +func TestDeleteAskSessionRemovesSession(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + _ = store.Save(context.Background(), AskSession{ID: "s1"}) + + s := &Service{askStore: store} + ok, err := s.DeleteAskSession(context.Background(), DeleteAskSessionInput{SessionID: "s1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected true when session was deleted") + } + _, loaded, _ := store.Load(context.Background(), "s1") + if loaded { + t.Fatal("session should have been deleted") + } +} + +// ---- AskSession.Clone() ---- + +func TestAskSessionCloneDeepCopy(t *testing.T) { + original := AskSession{ + ID: "s1", + Workdir: "/tmp", + Skills: []string{"skill-a"}, + Messages: []AskMessage{ + {Role: "user", Content: "hello"}, + }, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + } + cloned := original.Clone() + + cloned.Skills[0] = "skill-b" + cloned.Messages[0].Content = "modified" + + if original.Skills[0] != "skill-a" { + t.Fatal("original skills should not be affected by clone mutation") + } + if original.Messages[0].Content != "hello" { + t.Fatal("original messages should not be affected by clone mutation") + } + if cloned.ID != "s1" || cloned.Workdir != "/tmp" { + t.Fatalf("clone fields mismatch: ID=%q Workdir=%q", cloned.ID, cloned.Workdir) + } +} + +func TestCloneZeroSession(t *testing.T) { + cloned := AskSession{}.Clone() + if len(cloned.Skills) != 0 { + t.Fatalf("cloned skills should be empty, got %#v", cloned.Skills) + } + if len(cloned.Messages) != 0 { + t.Fatalf("cloned messages should be empty, got %#v", cloned.Messages) + } + if cloned.ID != "" { + t.Fatalf("cloned ID should be empty, got %q", cloned.ID) + } +} + +// ---- normalizeAskMessageRole ---- + +func TestNormalizeAskMessageRole(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"user", "user"}, + {"USER", "user"}, + {" User ", "user"}, + {"assistant", "assistant"}, + {"ASSISTANT", "assistant"}, + {"system", "assistant"}, + {"", "assistant"}, + {"unknown", "assistant"}, + } + for _, tt := range tests { + got := normalizeAskMessageRole(tt.input) + if got != tt.want { + t.Errorf("normalizeAskMessageRole(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// ---- ID generation ---- + +func TestGenerateAskSessionID(t *testing.T) { + s := &Service{} + id1 := s.generateAskSessionID() + id2 := s.generateAskSessionID() + if id1 == id2 { + t.Fatal("session IDs should be unique") + } + if !strings.HasPrefix(id1, "ask-") { + t.Fatalf("session ID should start with 'ask-', got %q", id1) + } + pidStr := fmt.Sprintf("-%d-", os.Getpid()) + if !strings.Contains(id1, pidStr) { + t.Fatalf("session ID should contain PID, got %q", id1) + } +} + +func TestGenerateAskRunID(t *testing.T) { + s := &Service{} + id1 := s.generateAskRunID() + id2 := s.generateAskRunID() + if id1 == id2 { + t.Fatal("run IDs should be unique") + } + if !strings.HasPrefix(id1, "ask-run-") { + t.Fatalf("run ID should start with 'ask-run-', got %q", id1) + } +} + +// ---- resolveAskPromptConfig ---- + +func TestResolveAskPromptConfigUsesDefaultsWhenNilService(t *testing.T) { + var s *Service + got := s.resolveAskPromptConfig() + if got.MaxInputTokens != config.DefaultAskMaxInputTokens { + t.Fatalf("MaxInputTokens = %d, want %d", got.MaxInputTokens, config.DefaultAskMaxInputTokens) + } + if got.RetainTurns != config.DefaultAskRetainTurns { + t.Fatalf("RetainTurns = %d, want %d", got.RetainTurns, config.DefaultAskRetainTurns) + } + if got.SummaryMaxChars != config.DefaultAskSummaryMaxChars { + t.Fatalf("SummaryMaxChars = %d, want %d", got.SummaryMaxChars, config.DefaultAskSummaryMaxChars) + } +} + +func TestResolveAskPromptConfigUsesDefaultsWithNoConfigManager(t *testing.T) { + s := &Service{} + got := s.resolveAskPromptConfig() + if got.MaxInputTokens != config.DefaultAskMaxInputTokens { + t.Fatalf("MaxInputTokens = %d, want %d", got.MaxInputTokens, config.DefaultAskMaxInputTokens) + } + if got.RetainTurns != config.DefaultAskRetainTurns { + t.Fatalf("RetainTurns = %d, want %d", got.RetainTurns, config.DefaultAskRetainTurns) + } + if got.SummaryMaxChars != config.DefaultAskSummaryMaxChars { + t.Fatalf("SummaryMaxChars = %d, want %d", got.SummaryMaxChars, config.DefaultAskSummaryMaxChars) + } +} + +func TestResolveAskPromptConfigMergesOverrides(t *testing.T) { + cm := newRuntimeConfigManager(t) + s := &Service{configManager: cm} + got := s.resolveAskPromptConfig() + // With newRuntimeConfigManager, the default Ask config values should be picked up + if got.MaxInputTokens != config.DefaultAskMaxInputTokens { + t.Fatalf("MaxInputTokens = %d, want %d", got.MaxInputTokens, config.DefaultAskMaxInputTokens) + } + if got.RetainTurns != config.DefaultAskRetainTurns { + t.Fatalf("RetainTurns = %d, want %d", got.RetainTurns, config.DefaultAskRetainTurns) + } + if got.SummaryMaxChars != config.DefaultAskSummaryMaxChars { + t.Fatalf("SummaryMaxChars = %d, want %d", got.SummaryMaxChars, config.DefaultAskSummaryMaxChars) + } +} + +// ---- normalizeAskSkillIDs ---- + +func TestNormalizeAskSkillIDs(t *testing.T) { + tests := []struct { + name string + input []string + expect []string + }{ + {"nil", nil, nil}, + {"empty", []string{}, nil}, + {"single", []string{"skill-a"}, []string{"skill-a"}}, + {"trim whitespace", []string{" skill-a "}, []string{"skill-a"}}, + {"dedup exact", []string{"skill-a", "skill-a"}, []string{"skill-a"}}, + {"dedup case insensitive", []string{"Skill-A", "skill-a"}, []string{"Skill-A"}}, + {"skip empty string", []string{"", "skill-a", ""}, []string{"skill-a"}}, + {"multiple skills", []string{"skill-a", "skill-b"}, []string{"skill-a", "skill-b"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeAskSkillIDs(tt.input) + if len(got) != len(tt.expect) { + t.Fatalf("len = %d, want %d: %#v", len(got), len(tt.expect), got) + } + for i := range got { + if got[i] != tt.expect[i] { + t.Fatalf("[%d] = %q, want %q", i, got[i], tt.expect[i]) + } + } + }) + } +} + +// ---- askMessagesToTurns ---- + +func TestAskMessagesToTurns(t *testing.T) { + tests := []struct { + name string + messages []AskMessage + want int + }{ + {"nil", nil, 0}, + {"empty", []AskMessage{}, 0}, + {"single user", []AskMessage{{Role: "user", Content: "q1"}}, 1}, + {"user+assistant", []AskMessage{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1"}, + }, 1}, + {"two turns", []AskMessage{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q2"}, + {Role: "assistant", Content: "a2"}, + }, 2}, + {"assistant without user ignored", []AskMessage{ + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q1"}, + }, 1}, + {"trailing user", []AskMessage{ + {Role: "user", Content: "q1"}, + {Role: "user", Content: "q2"}, + }, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + turns := askMessagesToTurns(tt.messages) + if len(turns) != tt.want { + t.Fatalf("len = %d, want %d: %#v", len(turns), tt.want, turns) + } + }) + } +} + +func TestAskMessagesToTurnsContent(t *testing.T) { + messages := []AskMessage{ + {Role: "user", Content: "what is go?"}, + {Role: "assistant", Content: "Go is a language."}, + } + turns := askMessagesToTurns(messages) + if len(turns) != 1 { + t.Fatalf("expected 1 turn, got %d", len(turns)) + } + if turns[0].UserQuery != "what is go?" { + t.Fatalf("UserQuery = %q", turns[0].UserQuery) + } + if turns[0].Assistant != "Go is a language." { + t.Fatalf("Assistant = %q", turns[0].Assistant) + } +} + +// ---- compactAskSessionMessages ---- + +func TestCompactAskSessionMessagesSummaryOnly(t *testing.T) { + result := agentcontext.AskPromptBuildResult{ + Summary: "previous conversations about Go", + } + messages := compactAskSessionMessages(result) + if len(messages) != 2 { + t.Fatalf("expected 2 messages (user + assistant), got %d", len(messages)) + } + if messages[0].Role != "user" || messages[0].Content != "之前对话摘要" { + t.Fatalf("first message should be summary marker, got %+v", messages[0]) + } + if messages[1].Role != "assistant" || messages[1].Content != "previous conversations about Go" { + t.Fatalf("second message should be summary, got %+v", messages[1]) + } +} + +func TestCompactAskSessionMessagesEmptySummary(t *testing.T) { + result := agentcontext.AskPromptBuildResult{ + Summary: " ", + } + messages := compactAskSessionMessages(result) + if len(messages) != 0 { + t.Fatalf("expected 0 messages for empty summary, got %d", len(messages)) + } +} + +func TestCompactAskSessionMessagesRetainedTurns(t *testing.T) { + result := agentcontext.AskPromptBuildResult{ + RetainedTurns: []agentcontext.AskTurn{ + {UserQuery: "q1", Assistant: "a1"}, + {UserQuery: "q2"}, + }, + } + messages := compactAskSessionMessages(result) + if len(messages) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(messages), messages) + } + if messages[0].Role != "user" || messages[0].Content != "q1" { + t.Fatalf("unexpected messages[0]: %+v", messages[0]) + } + if messages[1].Role != "assistant" || messages[1].Content != "a1" { + t.Fatalf("unexpected messages[1]: %+v", messages[1]) + } + if messages[2].Role != "user" || messages[2].Content != "q2" { + t.Fatalf("unexpected messages[2]: %+v", messages[2]) + } +} + +func TestCompactAskSessionMessagesSkipsEmptyQuery(t *testing.T) { + result := agentcontext.AskPromptBuildResult{ + RetainedTurns: []agentcontext.AskTurn{ + {UserQuery: " ", Assistant: "a1"}, + {UserQuery: "q1", Assistant: "a1"}, + }, + } + messages := compactAskSessionMessages(result) + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } +} + +func TestCompactAskSessionMessagesBothSummaryAndTurns(t *testing.T) { + result := agentcontext.AskPromptBuildResult{ + Summary: "summary text", + RetainedTurns: []agentcontext.AskTurn{ + {UserQuery: "q1", Assistant: "a1"}, + }, + } + messages := compactAskSessionMessages(result) + if len(messages) != 4 { + t.Fatalf("expected 4 messages, got %d", len(messages)) + } +} + +// ---- appendAskMessage ---- + +func TestAppendAskMessageEmptyContent(t *testing.T) { + session := AskSession{} + result := appendAskMessage(session, "user", " ") + if len(result.Messages) != 0 { + t.Fatal("expected no messages for empty content") + } +} + +func TestAppendAskMessageNormalizesRole(t *testing.T) { + session := AskSession{} + result := appendAskMessage(session, "USER", "hello") + if result.Messages[0].Role != "user" { + t.Fatalf("role = %q, want 'user'", result.Messages[0].Role) + } +} + +func TestAppendAskMessageTruncatesExcessTurns(t *testing.T) { + session := AskSession{} + for i := 0; i < askSessionMaxTurns+2; i++ { + session = appendAskMessage(session, "user", fmt.Sprintf("q%d", i)) + session = appendAskMessage(session, "assistant", fmt.Sprintf("a%d", i)) + } + turns := askMessagesToTurns(session.Messages) + if len(turns) > askSessionMaxTurns { + t.Fatalf("turns = %d, want <= %d", len(turns), askSessionMaxTurns) + } +} + +// ---- buildAskSystemPrompt ---- + +func TestBuildAskSystemPromptNoSkills(t *testing.T) { + got := buildAskSystemPrompt(context.Background(), nil, nil) + if !strings.Contains(got, "NeoCode Ask mode assistant") { + t.Fatalf("unexpected system prompt: %q", got) + } + if strings.Contains(got, "Skill") { + t.Fatalf("should not contain Skill section, got %q", got) + } +} + +func TestBuildAskSystemPromptEmptySkillIDs(t *testing.T) { + registry := newStubAskSkillsRegistry() + got := buildAskSystemPrompt(context.Background(), registry, []string{}) + if !strings.Contains(got, "NeoCode Ask mode assistant") { + t.Fatalf("unexpected system prompt: %q", got) + } + if strings.Contains(got, "Skill") { + t.Fatalf("should not contain Skill section, got %q", got) + } +} + +func TestBuildAskSystemPromptWithSkills(t *testing.T) { + registry := newStubAskSkillsRegistry() + registry.addSkill("s1", "do X") + registry.addSkill("s2", "do Y") + + got := buildAskSystemPrompt(context.Background(), registry, []string{" s1 ", "s2"}) + if !strings.Contains(got, "Skill `s1`") { + t.Fatalf("expected Skill s1, got %q", got) + } + if !strings.Contains(got, "do X") { + t.Fatalf("expected do X, got %q", got) + } + if !strings.Contains(got, "Skill `s2`") { + t.Fatalf("expected Skill s2, got %q", got) + } +} + +func TestBuildAskSystemPromptDedupSkills(t *testing.T) { + registry := newStubAskSkillsRegistry() + registry.addSkill("s1", "do X") + + got := buildAskSystemPrompt(context.Background(), registry, []string{"s1", "S1", " S1 "}) + count := strings.Count(got, "Skill `s1`") + if count != 1 { + t.Fatalf("Skill s1 should appear once, got %d occurrences in:\n%s", count, got) + } +} + +func TestBuildAskSystemPromptSkillNotFound(t *testing.T) { + registry := newStubAskSkillsRegistry() + got := buildAskSystemPrompt(context.Background(), registry, []string{"nonexistent"}) + if strings.Contains(got, "Skill") { + t.Fatalf("should not contain Skill section for missing skill, got %q", got) + } +} + +func TestBuildAskSystemPromptSkillEmptyInstruction(t *testing.T) { + registry := newStubAskSkillsRegistry() + registry.addSkill("s1", " ") + + got := buildAskSystemPrompt(context.Background(), registry, []string{"s1"}) + if strings.Contains(got, "Skill `s1`") { + t.Fatalf("should not contain Skill section for empty instruction, got %q", got) + } +} + +func TestBuildAskSystemPromptEmptySkillID(t *testing.T) { + registry := newStubAskSkillsRegistry() + registry.addSkill("s1", "do X") + + got := buildAskSystemPrompt(context.Background(), registry, []string{"", " "}) + if strings.Contains(got, "Skill") { + t.Fatalf("should not contain Skill section for empty IDs, got %q", got) + } +} + +func TestBuildAskSystemPromptNilRegistry(t *testing.T) { + got := buildAskSystemPrompt(context.Background(), nil, []string{"s1"}) + if !strings.Contains(got, "NeoCode Ask mode assistant") { + t.Fatalf("unexpected system prompt: %q", got) + } +} + +// ---- mapAskErrorCode ---- + +func TestMapAskErrorCode(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {"nil", nil, "INTERNAL_ERROR"}, + {"deadline", context.DeadlineExceeded, "TIMEOUT"}, + {"canceled", context.Canceled, "CANCELED"}, + {"rate limit", &provider.ProviderError{Code: provider.ErrorCodeRateLimit}, "RATE_LIMITED"}, + {"wrapped rate limit", fmt.Errorf("wrap: %w", &provider.ProviderError{Code: provider.ErrorCodeRateLimit}), "RATE_LIMITED"}, + {"timeout provider", &provider.ProviderError{Code: provider.ErrorCodeTimeout}, "TIMEOUT"}, + {"server error", &provider.ProviderError{Code: provider.ErrorCodeServer}, "PROVIDER_ERROR"}, + {"client error", &provider.ProviderError{Code: provider.ErrorCodeClient}, "PROVIDER_ERROR"}, + {"generic", errors.New("something"), "INTERNAL_ERROR"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapAskErrorCode(tt.err) + if got != tt.want { + t.Fatalf("mapAskErrorCode() = %q, want %q", got, tt.want) + } + }) + } +} + +// ---- emitAskError ---- + +func TestEmitAskErrorNilService(t *testing.T) { + var s *Service + s.emitAskError(context.Background(), "r1", "s1", errors.New("test")) +} + +func TestEmitAskErrorNilError(t *testing.T) { + s := &Service{} + s.emitAskError(context.Background(), "r1", "s1", nil) +} + +func TestEmitAskErrorWritesEvent(t *testing.T) { + cm := newRuntimeConfigManager(t) + ch := make(chan RuntimeEvent, 16) + s := &Service{ + configManager: cm, + events: ch, + } + + s.emitAskError(context.Background(), "r1", "s1", fmt.Errorf("test error: %w", &provider.ProviderError{ + Code: provider.ErrorCodeRateLimit, + Message: "too many requests", + })) + + select { + case event := <-ch: + if event.Type != EventError { + t.Fatalf("expected EventError, got %s", event.Type) + } + payload, ok := event.Payload.(map[string]any) + if !ok { + t.Fatal("payload should be map[string]any") + } + if payload["code"] != "RATE_LIMITED" { + t.Fatalf("code = %v, want RATE_LIMITED", payload["code"]) + } + if !strings.Contains(payload["message"].(string), "too many requests") { + t.Fatalf("message = %v", payload["message"]) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +// ---- captureAskRuntimeEvent ---- + +func TestCaptureAskRuntimeEvent(t *testing.T) { + s := &Service{} + s.captureAskRuntimeEvent(RuntimeEvent{Type: "test"}) +} + +// ---- streamGenerateResult ---- + +func TestStreamGenerateResultSuccess(t *testing.T) { + textResult := providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("hello world"), + }, + } + outcome := streamGenerateResult{ + message: textResult, + inputTokens: 10, + outputTokens: 5, + err: nil, + } + if outcome.err != nil { + t.Fatalf("unexpected error: %v", outcome.err) + } + if outcome.inputTokens != 10 || outcome.outputTokens != 5 { + t.Fatalf("unexpected token counts: %d/%d", outcome.inputTokens, outcome.outputTokens) + } + if outcome.message.Role != providertypes.RoleAssistant { + t.Fatalf("unexpected role: %s", outcome.message.Role) + } +} + +func TestStreamGenerateResultWithError(t *testing.T) { + outcome := streamGenerateResult{ + err: errors.New("stream failed"), + } + if outcome.err == nil { + t.Fatal("expected error") + } + if outcome.inputTokens != 0 || outcome.outputTokens != 0 { + t.Fatal("expected zero tokens for failed outcome") + } +} + +func TestStreamGenerateResultObservedFlags(t *testing.T) { + outcome := streamGenerateResult{ + inputTokens: 5, + outputTokens: 3, + inputObserved: true, + outputObserved: true, + } + if !outcome.inputObserved || !outcome.outputObserved { + t.Fatal("expected observed flags to be true") + } +} + +// ---- inMemoryAskSessionStore ---- + +func TestInMemoryAskSessionStoreLoadNotExists(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + _, loaded, err := store.Load(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded { + t.Fatal("expected false for nonexistent session") + } +} + +func TestInMemoryAskSessionStoreLoadEmptyID(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + _, loaded, err := store.Load(context.Background(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded { + t.Fatal("expected false for empty ID") + } +} + +func TestInMemoryAskSessionStoreSaveAndLoad(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + session := AskSession{ + ID: "s1", + Workdir: "/tmp/test", + Skills: []string{"skill-a"}, + Messages: []AskMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + }, + } + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("Save error: %v", err) + } + + loaded, found, err := store.Load(context.Background(), "s1") + if err != nil { + t.Fatalf("Load error: %v", err) + } + if !found { + t.Fatal("expected session to be found") + } + if loaded.ID != "s1" { + t.Fatalf("ID = %q", loaded.ID) + } + if loaded.Workdir != "/tmp/test" { + t.Fatalf("Workdir = %q", loaded.Workdir) + } + if len(loaded.Messages) != 2 { + t.Fatalf("messages = %d", len(loaded.Messages)) + } + if loaded.CreatedAt.IsZero() { + t.Fatal("CreatedAt should not be zero") + } +} + +func TestInMemoryAskSessionStoreSaveEmptyID(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + if err := store.Save(context.Background(), AskSession{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInMemoryAskSessionStoreDelete(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + _ = store.Save(context.Background(), AskSession{ID: "s1"}) + + ok, err := store.Delete(context.Background(), "s1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected true for existing session") + } + + _, found, _ := store.Load(context.Background(), "s1") + if found { + t.Fatal("session should have been deleted") + } +} + +func TestInMemoryAskSessionStoreDeleteNotExists(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + ok, err := store.Delete(context.Background(), "s1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false for nonexistent session") + } +} + +func TestInMemoryAskSessionStoreDeleteEmptyID(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + ok, err := store.Delete(context.Background(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected false for empty ID") + } +} + +func TestInMemoryAskSessionStoreContextCancelled(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + _ = store.Save(context.Background(), AskSession{ID: "s1"}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := store.Load(ctx, "s1") + if err == nil { + t.Fatal("expected context cancelled error from Load") + } + err = store.Save(ctx, AskSession{ID: "s1"}) + if err == nil { + t.Fatal("expected context cancelled error from Save") + } + _, err = store.Delete(ctx, "s1") + if err == nil { + t.Fatal("expected context cancelled error from Delete") + } +} + +func TestAskSessionStoreCleanupExpired(t *testing.T) { + store := newInMemoryAskSessionStore(10 * time.Millisecond) + _ = store.Save(context.Background(), AskSession{ID: "s1"}) + _ = store.Save(context.Background(), AskSession{ID: "s2"}) + + time.Sleep(20 * time.Millisecond) + + _, found, _ := store.Load(context.Background(), "s1") + if found { + t.Fatal("s1 should have expired") + } + _, found, _ = store.Load(context.Background(), "s2") + if found { + t.Fatal("s2 should have expired") + } +} + +func TestAskSessionStoreDefaultTTL(t *testing.T) { + store := newInMemoryAskSessionStore(0) + impl := store.(*inMemoryAskSessionStore) + if impl.ttl != askSessionTTL { + t.Fatalf("ttl = %v, want %v", impl.ttl, askSessionTTL) + } +} + +func TestAskSessionStoreUpdateRefreshesTimestamp(t *testing.T) { + store := newInMemoryAskSessionStore(100 * time.Millisecond) + _ = store.Save(context.Background(), AskSession{ID: "s1"}) + + time.Sleep(50 * time.Millisecond) + _, found, _ := store.Load(context.Background(), "s1") + if !found { + t.Fatal("s1 should still be alive after Load refresh") + } + + time.Sleep(60 * time.Millisecond) + _, found, _ = store.Load(context.Background(), "s1") + if !found { + t.Fatal("s1 should still be alive due to timestamp refresh") + } +} + +func TestAskSessionStoreCleanupHandlesZeroUpdateTime(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + impl := store.(*inMemoryAskSessionStore) + impl.sessions["s1"] = AskSession{ID: "s1"} + impl.cleanupExpiredLocked(time.Now().UTC()) + session, exists := impl.sessions["s1"] + if !exists { + t.Fatal("session with zero updatedAt should survive cleanup") + } + if session.UpdatedAt.IsZero() { + t.Fatal("UpdatedAt should be patched to now") + } +} + +func TestAskSessionStoreNilCleanup(t *testing.T) { + var s *inMemoryAskSessionStore + s.cleanupExpiredLocked(time.Now()) +} + +func TestAskSessionStoreNilCleanupZeroTTL(t *testing.T) { + s := &inMemoryAskSessionStore{ttl: 0} + s.cleanupExpiredLocked(time.Now()) +} + +func TestAskSessionStoreSaveWithoutCreatedAt(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + session := AskSession{ + ID: "s1", + Workdir: "/tmp", + } + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("Save error: %v", err) + } + loaded, found, _ := store.Load(context.Background(), "s1") + if !found { + t.Fatal("expected session to be found") + } + if loaded.CreatedAt.IsZero() { + t.Fatal("CreatedAt should be auto-filled") + } +} + +func TestAskSessionStoreSaveConflictIsLastWriteWins(t *testing.T) { + store := newInMemoryAskSessionStore(time.Hour) + _ = store.Save(context.Background(), AskSession{ID: "s1", Workdir: "/first"}) + _ = store.Save(context.Background(), AskSession{ID: "s1", Workdir: "/second"}) + + loaded, found, _ := store.Load(context.Background(), "s1") + if !found { + t.Fatal("expected session to be found") + } + if loaded.Workdir != "/second" { + t.Fatalf("Workdir = %q, want /second", loaded.Workdir) + } +} diff --git a/internal/runtime/controlplane/stop_reason.go b/internal/runtime/controlplane/stop_reason.go index 84391649f..e7dad8600 100644 --- a/internal/runtime/controlplane/stop_reason.go +++ b/internal/runtime/controlplane/stop_reason.go @@ -18,8 +18,10 @@ const ( StopReasonAccepted StopReason = "accepted" // StopReasonEmptyResponse 表示模型返回空文本响应。 StopReasonEmptyResponse StopReason = "empty_response" - // StopReasonAcceptCheckFailed 表示最终 Accept Gate 的验收项失败。 - StopReasonAcceptCheckFailed StopReason = "accept_check_failed" + // StopReasonAcceptContinue 表示验收流程要求模型继续工作。 + StopReasonAcceptContinue StopReason = "accept_continue" + // StopReasonAcceptContinueExhausted 表示验收继续次数已耗尽。 + StopReasonAcceptContinueExhausted StopReason = "accept_continue_exhausted" // StopReasonTodoNotConverged 表示 required todo 尚未收敛。 StopReasonTodoNotConverged StopReason = "todo_not_converged" // StopReasonTodoWaitingExternal 表示 required todo 仍在等待外部条件。 @@ -28,8 +30,6 @@ const ( StopReasonRepeatCycle StopReason = "repeat_cycle" // StopReasonMaxTurnExceededWithUnconvergedTodos 表示达到最大轮次时 todo 仍未收敛。 StopReasonMaxTurnExceededWithUnconvergedTodos StopReason = "max_turn_exceeded_with_unconverged_todos" - // StopReasonMaxTurnExceededWithFailedVerification 表示达到最大轮次时 verifier 已失败。 - StopReasonMaxTurnExceededWithFailedVerification StopReason = "max_turn_exceeded_with_failed_verification" // StopReasonVerificationConfigMissing 表示 verifier 依赖的配置缺失或非法。 StopReasonVerificationConfigMissing StopReason = "verification_config_missing" // StopReasonVerificationExecutionDenied 表示 verifier 命令被执行策略拒绝。 diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 8b6c0a634..3a7b447fd 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -95,10 +95,11 @@ type VerificationFailedPayload struct { // AcceptanceDecidedPayload 描述 acceptance engine 决议结果。 type AcceptanceDecidedPayload struct { - Status string `json:"status"` - StopReason controlplane.StopReason `json:"stop_reason,omitempty"` - Summary string `json:"summary,omitempty"` - Results []acceptgate.CheckResult `json:"results,omitempty"` + Status string `json:"status"` + StopReason controlplane.StopReason `json:"stop_reason,omitempty"` + Summary string `json:"summary,omitempty"` + ContinueHint string `json:"continue_hint,omitempty"` + Results []acceptgate.CheckResult `json:"results,omitempty"` } // LedgerReconciledPayload 为账本对账预留负载。 @@ -410,12 +411,10 @@ const ( EventRepoHooksTrustStoreInvalid EventType = "repo_hooks_trust_store_invalid" // EventRuntimeSnapshotUpdated 表示 runtime 统一状态快照已更新。 EventRuntimeSnapshotUpdated EventType = "runtime_snapshot_updated" - // EventFactsUpdated 表示运行事实快照已更新。 - EventFactsUpdated EventType = "facts_updated" + // EventSubAgentSnapshotUpdated 表示子代理聚合快照已更新。 + EventSubAgentSnapshotUpdated EventType = "subagent_snapshot_updated" // EventDecisionMade 表示 FinalDecider 已输出裁决。 EventDecisionMade EventType = "decision_made" - // EventSubAgentSnapshotUpdated 表示子代理状态快照已更新。 - EventSubAgentSnapshotUpdated EventType = "subagent_snapshot_updated" // EventTodoSnapshotUpdated 表示 todo 快照已更新。 EventTodoSnapshotUpdated EventType = "todo_snapshot_updated" diff --git a/internal/runtime/facts/collector.go b/internal/runtime/facts/collector.go deleted file mode 100644 index a4a78f606..000000000 --- a/internal/runtime/facts/collector.go +++ /dev/null @@ -1,638 +0,0 @@ -package facts - -import ( - "fmt" - "sort" - "strconv" - "strings" - - "neo-code/internal/tools" -) - -// Collector 负责把分散的工具/子代理/待办状态信号收敛为统一事实层。 -type Collector struct { - facts RuntimeFacts - seen map[string]struct{} -} - -// NewCollector 创建一个空事实收集器。 -func NewCollector() *Collector { - return &Collector{ - seen: make(map[string]struct{}), - } -} - -// Snapshot 返回当前事实快照的深拷贝。 -func (c *Collector) Snapshot() RuntimeFacts { - if c == nil { - return RuntimeFacts{} - } - return cloneRuntimeFacts(c.facts) -} - -// ApplyTodoSnapshot 将最新 todo 汇总写入事实层。 -func (c *Collector) ApplyTodoSnapshot(summary TodoSummaryLike) { - if c == nil { - return - } - c.facts.Todos.OpenRequiredCount = maxInt(0, summary.RequiredOpen) - c.facts.Todos.CompletedRequiredCount = maxInt(0, summary.RequiredCompleted) - c.facts.Todos.FailedRequiredCount = maxInt(0, summary.RequiredFailed) -} - -// ApplyTodoConflict 记录 todo 冲突事实,供终态决策识别重复不可恢复失败。 -func (c *Collector) ApplyTodoConflict(todoIDs []string) { - if c == nil { - return - } - for _, id := range normalizeStringList(todoIDs) { - if c.markSeen("todo_conflict:" + id) { - c.facts.Todos.ConflictIDs = append(c.facts.Todos.ConflictIDs, id) - c.facts.Progress.ObservedFactCount++ - } - } -} - -// ApplyToolResult 将工具结果解析为结构化事实。 -func (c *Collector) ApplyToolResult(toolName string, result tools.ToolResult) { - if c == nil { - return - } - name := strings.TrimSpace(toolName) - if name == "" { - name = strings.TrimSpace(result.Name) - } - if name == "" { - return - } - normalizedName := strings.ToLower(name) - if result.IsError { - c.applyToolErrorFact(name, result) - } - - switch normalizedName { - case strings.ToLower(tools.ToolNameTodoWrite): - if result.IsError { - break - } - c.applyTodoToolFacts(result) - case strings.ToLower(tools.ToolNameFilesystemWriteFile): - if result.IsError { - break - } - c.applyWriteFileFacts(result) - case strings.ToLower(tools.ToolNameFilesystemReadFile): - if result.IsError { - break - } - c.applyReadFileFacts(result) - case strings.ToLower(tools.ToolNameFilesystemEdit): - if result.IsError { - break - } - c.applyEditFileFacts(result) - case strings.ToLower(tools.ToolNameFilesystemGlob): - if result.IsError { - break - } - c.applyGlobFacts(result) - case strings.ToLower(tools.ToolNameBash): - c.applyCommandFacts(name, result) - if !result.IsError { - c.applyWorkspaceWritePathFacts(result, "bash") - } - case strings.ToLower(tools.ToolNameSpawnSubAgent): - c.applySpawnSubAgentFacts(result) - default: - // 其他工具暂不扩展领域事实,避免噪声扩散。 - } - - c.applyVerificationFacts(name, result) -} - -// applyWorkspaceWritePathFacts 将工具 metadata 中声明的写入路径转成可被 Accept Gate 消费的文件事实。 -func (c *Collector) applyWorkspaceWritePathFacts(result tools.ToolResult, source string) { - if !result.Facts.WorkspaceWrite { - return - } - paths := readStringSlice(result.Metadata, "workspace_write_paths") - if len(paths) == 0 { - return - } - source = strings.TrimSpace(source) - if source == "" { - source = "workspace_write" - } - for _, path := range paths { - key := fmt.Sprintf("file_written:%s:%s", source, path) - if c.markSeen(key) { - c.facts.Files.Written = append(c.facts.Files.Written, FileWriteFact{ - Path: path, - WorkspaceWrite: true, - }) - c.facts.Progress.ObservedFactCount++ - } - existsKey := fmt.Sprintf("file_exists:%s:%s", source, path) - if c.markSeen(existsKey) { - c.facts.Files.Exists = append(c.facts.Files.Exists, FileExistFact{Path: path, Source: source}) - c.facts.Progress.ObservedFactCount++ - } - } -} - -// applyToolErrorFact 记录工具错误事实,供终态决策识别持续失败模式。 -func (c *Collector) applyToolErrorFact(toolName string, result tools.ToolResult) { - errorClass := strings.TrimSpace(result.ErrorClass) - if errorClass == "" { - errorClass = "generic_error" - } - content := strings.TrimSpace(result.Content) - if len(content) > 256 { - content = content[:256] - } - key := fmt.Sprintf("tool_error:%s:%s", strings.ToLower(strings.TrimSpace(toolName)), errorClass) - if !c.markSeen(key) { - return - } - c.facts.Errors.ToolErrors = append(c.facts.Errors.ToolErrors, ToolErrorFact{ - Tool: strings.TrimSpace(toolName), - ErrorClass: errorClass, - Content: content, - }) -} - -// ApplySubAgentStarted 记录子代理启动事实。 -func (c *Collector) ApplySubAgentStarted(fact SubAgentFact) { - if c == nil { - return - } - fact.TaskID = strings.TrimSpace(fact.TaskID) - if fact.TaskID == "" { - return - } - key := fmt.Sprintf("subagent_started:%s:%s", fact.TaskID, strings.TrimSpace(fact.Role)) - if !c.markSeen(key) { - return - } - c.facts.SubAgents.Started = append(c.facts.SubAgents.Started, fact) - c.facts.Progress.ObservedFactCount++ -} - -// ApplySubAgentFinished 记录子代理完成/失败事实。 -func (c *Collector) ApplySubAgentFinished(fact SubAgentFact, succeeded bool) { - if c == nil { - return - } - fact.TaskID = strings.TrimSpace(fact.TaskID) - if fact.TaskID == "" { - return - } - state := "failed" - if succeeded { - state = "completed" - } - key := fmt.Sprintf("subagent_%s:%s:%s", state, fact.TaskID, strings.TrimSpace(fact.StopReason)) - if !c.markSeen(key) { - return - } - if succeeded { - c.facts.SubAgents.Completed = append(c.facts.SubAgents.Completed, fact) - } else { - c.facts.SubAgents.Failed = append(c.facts.SubAgents.Failed, fact) - } - c.facts.Progress.ObservedFactCount++ -} - -// applyTodoToolFacts 从 todo_write 元数据提取 todo 状态事实。 -func (c *Collector) applyTodoToolFacts(result tools.ToolResult) { - stateFact, _ := result.Metadata["state_fact"].(string) - stateFact = strings.TrimSpace(stateFact) - if stateFact == "" { - return - } - - todoIDs := normalizeStringList(readTodoIDsFromMetadata(result.Metadata)) - if len(todoIDs) == 0 { - if id, ok := readString(result.Metadata, "id"); ok { - todoIDs = []string{id} - } - } - if len(todoIDs) == 0 { - todoIDs = []string{"unknown"} - } - for _, todoID := range todoIDs { - key := "todo_state:" + stateFact + ":" + todoID - if !c.markSeen(key) { - continue - } - switch stateFact { - case "todo_created": - c.facts.Todos.CreatedIDs = append(c.facts.Todos.CreatedIDs, todoID) - case "todo_updated": - c.facts.Todos.UpdatedIDs = append(c.facts.Todos.UpdatedIDs, todoID) - case "todo_completed": - c.facts.Todos.CompletedIDs = append(c.facts.Todos.CompletedIDs, todoID) - case "todo_failed": - c.facts.Todos.FailedIDs = append(c.facts.Todos.FailedIDs, todoID) - default: - c.facts.Todos.UpdatedIDs = append(c.facts.Todos.UpdatedIDs, todoID) - } - c.facts.Progress.ObservedFactCount++ - } -} - -// applyWriteFileFacts 从写文件工具结果提取 workspace write 事实。 -func (c *Collector) applyWriteFileFacts(result tools.ToolResult) { - path, ok := readString(result.Metadata, "path") - if !ok { - return - } - noopWrite := readBool(result.Metadata, "noop_write") - if !noopWrite { - bytes := readInt(result.Metadata, "bytes") - key := fmt.Sprintf("file_written:%s:%d", path, bytes) - if c.markSeen(key) { - c.facts.Files.Written = append(c.facts.Files.Written, FileWriteFact{ - Path: path, - Bytes: bytes, - WorkspaceWrite: true, - ExpectedContent: readStringDefault(result.Metadata, "written_content"), - }) - c.facts.Progress.ObservedFactCount++ - } - } - existsSource := "filesystem_write_file" - if noopWrite { - existsSource = "filesystem_write_file_noop" - } - existsKey := fmt.Sprintf("file_exists:write:%s:%s", existsSource, path) - if c.markSeen(existsKey) { - c.facts.Files.Exists = append(c.facts.Files.Exists, FileExistFact{Path: path, Source: existsSource}) - c.facts.Progress.ObservedFactCount++ - } - if !result.Facts.VerificationPerformed { - return - } - contentFact := FileContentMatchFact{ - Path: path, - Scope: strings.TrimSpace(result.Facts.VerificationScope), - ExpectedContains: normalizeStringList(readStringSlice(result.Metadata, "verification_expected")), - VerificationPassed: result.Facts.VerificationPassed, - } - status := "failed" - if contentFact.VerificationPassed { - status = "passed" - } - matchSource := "write" - if noopWrite { - matchSource = "noop_write" - } - matchKey := fmt.Sprintf("file_content_match:%s:%s:%s:%s", matchSource, path, contentFact.Scope, status) - if !c.markSeen(matchKey) { - return - } - c.facts.Files.ContentMatch = append(c.facts.Files.ContentMatch, contentFact) - c.facts.Progress.ObservedFactCount++ -} - -// applyEditFileFacts 从编辑工具结果提取 workspace write 事实,保证 edit 任务可进入统一验收链路。 -func (c *Collector) applyEditFileFacts(result tools.ToolResult) { - path, ok := readString(result.Metadata, "path") - if !ok { - return - } - key := fmt.Sprintf("file_written:edit:%s", path) - if !c.markSeen(key) { - return - } - c.facts.Files.Written = append(c.facts.Files.Written, FileWriteFact{ - Path: path, - Bytes: readInt(result.Metadata, "replacement_length"), - WorkspaceWrite: true, - }) - c.facts.Progress.ObservedFactCount++ -} - -// applyReadFileFacts 从 read_file 工具结果提取存在性和内容匹配事实。 -func (c *Collector) applyReadFileFacts(result tools.ToolResult) { - path, ok := readString(result.Metadata, "path") - if !ok { - return - } - if c.markSeen("file_exists:read:" + path) { - c.facts.Files.Exists = append(c.facts.Files.Exists, FileExistFact{Path: path, Source: "filesystem_read_file"}) - c.facts.Progress.ObservedFactCount++ - } - if !result.Facts.VerificationPerformed { - return - } - fact := FileContentMatchFact{ - Path: path, - Scope: strings.TrimSpace(result.Facts.VerificationScope), - ExpectedContains: normalizeStringList(readStringSlice(result.Metadata, "verification_expected")), - VerificationPassed: result.Facts.VerificationPassed, - } - status := "failed" - if fact.VerificationPassed { - status = "passed" - } - key := fmt.Sprintf("file_content_match:%s:%s:%s", path, fact.Scope, status) - if !c.markSeen(key) { - return - } - c.facts.Files.ContentMatch = append(c.facts.Files.ContentMatch, fact) - c.facts.Progress.ObservedFactCount++ -} - -// applyGlobFacts 从 glob 工具结果提取存在性与验证事实。 -func (c *Collector) applyGlobFacts(result tools.ToolResult) { - lines := normalizeStringList(strings.Split(result.Content, "\n")) - for _, line := range lines { - key := "file_exists:glob:" + line - if !c.markSeen(key) { - continue - } - c.facts.Files.Exists = append(c.facts.Files.Exists, FileExistFact{Path: line, Source: "filesystem_glob"}) - c.facts.Progress.ObservedFactCount++ - } -} - -// applyCommandFacts 从 bash 工具结果提取命令执行事实。 -func (c *Collector) applyCommandFacts(toolName string, result tools.ToolResult) { - exitCode := readInt(result.Metadata, "exit_code") - command, _ := readString(result.Metadata, "normalized_intent") - if command == "" { - command, _ = readString(result.Metadata, "command") - } - succeeded := readBool(result.Metadata, "ok") && exitCode == 0 - key := fmt.Sprintf("command:%s:%d:%t", command, exitCode, succeeded) - if !c.markSeen(key) { - return - } - c.facts.Commands.Executed = append(c.facts.Commands.Executed, CommandFact{ - Tool: strings.TrimSpace(toolName), - Command: command, - ExitCode: exitCode, - Succeeded: succeeded, - }) - c.facts.Progress.ObservedFactCount++ -} - -// applySpawnSubAgentFacts 从 spawn_subagent 工具结果提取结构化子代理事实。 -func (c *Collector) applySpawnSubAgentFacts(result tools.ToolResult) { - taskID, ok := readString(result.Metadata, "task_id") - if !ok { - return - } - role, _ := readString(result.Metadata, "role") - state, _ := readString(result.Metadata, "state") - stopReason, _ := readString(result.Metadata, "stop_reason") - summary := extractSubAgentSummary(result.Content) - fact := SubAgentFact{ - TaskID: taskID, - Role: role, - StopReason: stopReason, - Summary: summary, - Artifacts: normalizeStringList(readStringSlice(result.Metadata, "artifacts")), - } - c.ApplySubAgentStarted(fact) - if strings.EqualFold(state, "succeeded") { - c.ApplySubAgentFinished(fact, true) - return - } - if strings.EqualFold(state, "failed") || strings.EqualFold(state, "canceled") { - c.ApplySubAgentFinished(fact, false) - } -} - -// applyVerificationFacts 把工具声明的验证事实收敛到统一验证集合。 -func (c *Collector) applyVerificationFacts(toolName string, result tools.ToolResult) { - if !result.Facts.VerificationPerformed { - return - } - passed := result.Facts.VerificationPassed - fact := VerificationFact{ - Tool: strings.TrimSpace(toolName), - Scope: strings.TrimSpace(result.Facts.VerificationScope), - Reason: strings.TrimSpace(readStringDefault(result.Metadata, "verification_reason")), - } - status := "failed" - if passed { - status = "passed" - } - key := fmt.Sprintf("verification:%s:%s:%s", fact.Tool, fact.Scope, status) - if !c.markSeen(key) { - return - } - c.facts.Verification.Performed = append(c.facts.Verification.Performed, fact) - if passed { - c.facts.Verification.Passed = append(c.facts.Verification.Passed, fact) - } else { - c.facts.Verification.Failed = append(c.facts.Verification.Failed, fact) - } - c.facts.Progress.ObservedFactCount++ -} - -// markSeen 记录去重键,返回该键是否首次出现。 -func (c *Collector) markSeen(key string) bool { - if c == nil { - return false - } - trimmed := strings.TrimSpace(key) - if trimmed == "" { - return false - } - if c.seen == nil { - c.seen = make(map[string]struct{}) - } - if _, exists := c.seen[trimmed]; exists { - return false - } - c.seen[trimmed] = struct{}{} - return true -} - -// extractSubAgentSummary 从 spawn_subagent 文本结果中提取 summary 段,避免全文进入事实层。 -func extractSubAgentSummary(content string) string { - lines := strings.Split(content, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(strings.ToLower(trimmed), "summary:") { - return strings.TrimSpace(strings.TrimPrefix(trimmed, "summary:")) - } - } - return "" -} - -// readTodoIDsFromMetadata 从 todo 元数据中尽可能提取 todo id 列表。 -func readTodoIDsFromMetadata(metadata map[string]any) []string { - keys := []string{"todo_ids", "ids", "items"} - for _, key := range keys { - values := readStringSlice(metadata, key) - if len(values) > 0 { - return values - } - } - return nil -} - -// readString 从 metadata 读取字符串并做 trim。 -func readString(metadata map[string]any, key string) (string, bool) { - if metadata == nil { - return "", false - } - raw, ok := metadata[key] - if !ok || raw == nil { - return "", false - } - value := strings.TrimSpace(fmt.Sprintf("%v", raw)) - if value == "" { - return "", false - } - return value, true -} - -// readStringDefault 从 metadata 读取字符串,失败时返回空串。 -func readStringDefault(metadata map[string]any, key string) string { - value, _ := readString(metadata, key) - return value -} - -// readInt 从 metadata 读取整数值,不可解析时返回 0。 -func readInt(metadata map[string]any, key string) int { - if metadata == nil { - return 0 - } - raw, ok := metadata[key] - if !ok || raw == nil { - return 0 - } - switch typed := raw.(type) { - case int: - return typed - case int8: - return int(typed) - case int16: - return int(typed) - case int32: - return int(typed) - case int64: - return int(typed) - case float32: - return int(typed) - case float64: - return int(typed) - case string: - if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil { - return parsed - } - } - return 0 -} - -// readBool 从 metadata 读取布尔值,不可解析时返回 false。 -func readBool(metadata map[string]any, key string) bool { - if metadata == nil { - return false - } - raw, ok := metadata[key] - if !ok || raw == nil { - return false - } - switch typed := raw.(type) { - case bool: - return typed - case string: - return strings.EqualFold(strings.TrimSpace(typed), "true") - default: - return false - } -} - -// readStringSlice 从 metadata 读取字符串数组并做去空白、去重处理。 -func readStringSlice(metadata map[string]any, key string) []string { - if metadata == nil { - return nil - } - raw, ok := metadata[key] - if !ok || raw == nil { - return nil - } - switch typed := raw.(type) { - case []string: - return normalizeStringList(typed) - case []any: - values := make([]string, 0, len(typed)) - for _, item := range typed { - text := strings.TrimSpace(fmt.Sprintf("%v", item)) - if text == "" { - continue - } - values = append(values, text) - } - return normalizeStringList(values) - default: - return nil - } -} - -// normalizeStringList 对字符串列表执行 trim、去重和排序,保证事实输出稳定可测。 -func normalizeStringList(values []string) []string { - if len(values) == 0 { - return nil - } - seen := make(map[string]struct{}, len(values)) - out := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - if _, exists := seen[trimmed]; exists { - continue - } - seen[trimmed] = struct{}{} - out = append(out, trimmed) - } - if len(out) == 0 { - return nil - } - sort.Strings(out) - return out -} - -// cloneRuntimeFacts 深拷贝事实快照,避免调用方误改内部状态。 -func cloneRuntimeFacts(in RuntimeFacts) RuntimeFacts { - out := in - out.Todos.CreatedIDs = append([]string(nil), in.Todos.CreatedIDs...) - out.Todos.UpdatedIDs = append([]string(nil), in.Todos.UpdatedIDs...) - out.Todos.CompletedIDs = append([]string(nil), in.Todos.CompletedIDs...) - out.Todos.FailedIDs = append([]string(nil), in.Todos.FailedIDs...) - out.Todos.ConflictIDs = append([]string(nil), in.Todos.ConflictIDs...) - out.Files.Written = append([]FileWriteFact(nil), in.Files.Written...) - out.Files.Exists = append([]FileExistFact(nil), in.Files.Exists...) - out.Files.ContentMatch = append([]FileContentMatchFact(nil), in.Files.ContentMatch...) - out.Commands.Executed = append([]CommandFact(nil), in.Commands.Executed...) - out.SubAgents.Started = append([]SubAgentFact(nil), in.SubAgents.Started...) - out.SubAgents.Completed = append([]SubAgentFact(nil), in.SubAgents.Completed...) - out.SubAgents.Failed = append([]SubAgentFact(nil), in.SubAgents.Failed...) - out.Verification.Performed = append([]VerificationFact(nil), in.Verification.Performed...) - out.Verification.Passed = append([]VerificationFact(nil), in.Verification.Passed...) - out.Verification.Failed = append([]VerificationFact(nil), in.Verification.Failed...) - return out -} - -// maxInt 返回两个整数中的较大值。 -func maxInt(a int, b int) int { - if a > b { - return a - } - return b -} - -// TodoSummaryLike 提供对 todo summary 的最小字段依赖,避免 facts 包反向依赖 runtime 包。 -type TodoSummaryLike struct { - RequiredOpen int - RequiredCompleted int - RequiredFailed int -} diff --git a/internal/runtime/facts/collector_additional_test.go b/internal/runtime/facts/collector_additional_test.go deleted file mode 100644 index aaeeff437..000000000 --- a/internal/runtime/facts/collector_additional_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package facts - -import ( - "testing" - - "neo-code/internal/tools" -) - -func TestCollectorLowLevelHelpers(t *testing.T) { - t.Parallel() - - if got := readInt(map[string]any{"v": int8(3)}, "v"); got != 3 { - t.Fatalf("int8 readInt = %d, want 3", got) - } - if got := readInt(map[string]any{"v": int16(4)}, "v"); got != 4 { - t.Fatalf("int16 readInt = %d, want 4", got) - } - if got := readInt(map[string]any{"v": int32(5)}, "v"); got != 5 { - t.Fatalf("int32 readInt = %d, want 5", got) - } - if got := readInt(map[string]any{"v": int64(6)}, "v"); got != 6 { - t.Fatalf("int64 readInt = %d, want 6", got) - } - if got := readInt(map[string]any{"v": float32(7)}, "v"); got != 7 { - t.Fatalf("float32 readInt = %d, want 7", got) - } - if got := readInt(map[string]any{"v": float64(8)}, "v"); got != 8 { - t.Fatalf("float64 readInt = %d, want 8", got) - } - if got := readInt(map[string]any{"v": "not-int"}, "v"); got != 0 { - t.Fatalf("invalid string readInt = %d, want 0", got) - } - - if readBool(map[string]any{"v": "TRUE"}, "v") != true { - t.Fatal("readBool string true should be true") - } - if readBool(map[string]any{"v": 1}, "v") != false { - t.Fatal("readBool non-bool/non-string should be false") - } - - if values := readStringSlice(map[string]any{"v": []any{" a ", "", 2, "a"}}, "v"); len(values) != 2 || values[0] != "2" || values[1] != "a" { - t.Fatalf("readStringSlice = %+v, want [2 a]", values) - } - if values := normalizeStringList([]string{" b ", "", "a", "a"}); len(values) != 2 || values[0] != "a" || values[1] != "b" { - t.Fatalf("normalizeStringList = %+v", values) - } - - c := &Collector{} - if c.markSeen(" ") { - t.Fatal("blank key must not be marked") - } - if !c.markSeen("k") || c.markSeen("k") { - t.Fatal("markSeen dedupe failed") - } -} - -func TestCollectorBranchPaths(t *testing.T) { - t.Parallel() - - collector := NewCollector() - collector.ApplyToolResult("unknown_tool", tools.ToolResult{Name: "unknown_tool"}) - collector.ApplySubAgentStarted(SubAgentFact{}) - collector.ApplySubAgentFinished(SubAgentFact{}, true) - collector.ApplySubAgentFinished(SubAgentFact{TaskID: "sa", StopReason: "err"}, false) - collector.ApplySubAgentFinished(SubAgentFact{TaskID: "sa", StopReason: "err"}, false) - - snapshot := collector.Snapshot() - if len(snapshot.SubAgents.Failed) != 1 { - t.Fatalf("failed subagents = %+v, want deduped one", snapshot.SubAgents.Failed) - } -} diff --git a/internal/runtime/facts/collector_test.go b/internal/runtime/facts/collector_test.go deleted file mode 100644 index 1fa209bf3..000000000 --- a/internal/runtime/facts/collector_test.go +++ /dev/null @@ -1,412 +0,0 @@ -package facts - -import ( - "encoding/json" - "strings" - "testing" - - "neo-code/internal/tools" -) - -func TestCollectorApplyToolResultTodoAndVerificationFacts(t *testing.T) { - collector := NewCollector() - - collector.ApplyToolResult(tools.ToolNameTodoWrite, tools.ToolResult{ - Name: tools.ToolNameTodoWrite, - IsError: false, - Metadata: map[string]any{ - "state_fact": "todo_created", - "todo_ids": []string{"todo-1"}, - }, - }) - - collector.ApplyToolResult(tools.ToolNameFilesystemWriteFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemWriteFile, - IsError: false, - Metadata: map[string]any{ - "path": "test.txt", - "bytes": 1, - "written_content": "1", - }, - Facts: tools.ToolExecutionFacts{ - WorkspaceWrite: true, - }, - }) - - collector.ApplyToolResult(tools.ToolNameFilesystemReadFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemReadFile, - IsError: false, - Content: "1", - Metadata: map[string]any{ - "path": "test.txt", - "verification_expected": []string{"1"}, - "verification_reason": "content_match", - }, - Facts: tools.ToolExecutionFacts{ - VerificationPerformed: true, - VerificationPassed: true, - VerificationScope: "artifact:test.txt", - }, - }) - collector.ApplyToolResult(tools.ToolNameFilesystemEdit, tools.ToolResult{ - Name: tools.ToolNameFilesystemEdit, - IsError: false, - Metadata: map[string]any{ - "path": "edit.go", - "replacement_length": 12, - }, - }) - collector.ApplyToolResult(tools.ToolNameFilesystemWriteFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemWriteFile, - IsError: false, - Metadata: map[string]any{ - "path": "test.txt", - "bytes": 1, - "noop_write": true, - }, - }) - - snapshot := collector.Snapshot() - if len(snapshot.Todos.CreatedIDs) != 1 || snapshot.Todos.CreatedIDs[0] != "todo-1" { - t.Fatalf("todo created facts = %+v", snapshot.Todos.CreatedIDs) - } - if len(snapshot.Files.Written) != 2 || snapshot.Files.Written[0].Path != "test.txt" || snapshot.Files.Written[1].Path != "edit.go" { - t.Fatalf("file written facts = %+v", snapshot.Files.Written) - } - if snapshot.Files.Written[0].ExpectedContent != "1" { - t.Fatalf("expected content = %q, want 1", snapshot.Files.Written[0].ExpectedContent) - } - if len(snapshot.Files.Exists) == 0 || snapshot.Files.Exists[0].Path != "test.txt" { - t.Fatalf("file exists facts = %+v", snapshot.Files.Exists) - } - if len(snapshot.Verification.Passed) != 1 { - t.Fatalf("verification passed facts = %+v", snapshot.Verification.Passed) - } - if snapshot.Progress.ObservedFactCount < 3 { - t.Fatalf("observed fact count = %d, want >= 3", snapshot.Progress.ObservedFactCount) - } -} - -func TestCollectorApplyTodoConflictAndSubAgentFacts(t *testing.T) { - collector := NewCollector() - collector.ApplyTodoSnapshot(TodoSummaryLike{ - RequiredOpen: 1, - RequiredCompleted: 0, - RequiredFailed: 0, - }) - collector.ApplyTodoConflict([]string{"todo-1"}) - collector.ApplyTodoConflict([]string{"todo-1"}) // duplicate should be deduped - - collector.ApplyToolResult(tools.ToolNameSpawnSubAgent, tools.ToolResult{ - Name: tools.ToolNameSpawnSubAgent, - IsError: false, - Content: "Summary: done", - Metadata: map[string]any{ - "task_id": "sa-1", - "role": "reviewer", - "state": "succeeded", - }, - }) - - snapshot := collector.Snapshot() - if snapshot.Todos.OpenRequiredCount != 1 { - t.Fatalf("open required count = %d, want 1", snapshot.Todos.OpenRequiredCount) - } - if len(snapshot.Todos.ConflictIDs) != 1 || snapshot.Todos.ConflictIDs[0] != "todo-1" { - t.Fatalf("todo conflict ids = %+v", snapshot.Todos.ConflictIDs) - } - if len(snapshot.SubAgents.Started) != 1 || len(snapshot.SubAgents.Completed) != 1 { - t.Fatalf("subagent facts = %+v", snapshot.SubAgents) - } - if snapshot.SubAgents.Completed[0].TaskID != "sa-1" { - t.Fatalf("subagent completed task_id = %q, want sa-1", snapshot.SubAgents.Completed[0].TaskID) - } - if len(snapshot.SubAgents.Failed) != 0 { - t.Fatalf("failed subagent facts should be empty, got %+v", snapshot.SubAgents.Failed) - } -} - -func TestCollectorCapturesErrorFactsForToolErrors(t *testing.T) { - collector := NewCollector() - collector.ApplyToolResult(tools.ToolNameBash, tools.ToolResult{ - Name: tools.ToolNameBash, - IsError: true, - ErrorClass: "permission_denied", - Content: "permission denied", - Metadata: map[string]any{ - "exit_code": 1, - "normalized_intent": "cat README.md", - "ok": false, - }, - }) - collector.ApplyToolResult(tools.ToolNameSpawnSubAgent, tools.ToolResult{ - Name: tools.ToolNameSpawnSubAgent, - IsError: true, - ErrorClass: "subagent_failed", - Content: "runtime: subagent output key \"findings\" must be []string", - Metadata: map[string]any{ - "task_id": "spawn-1", - "role": "reviewer", - "state": "failed", - "stop_reason": "error", - }, - }) - - snapshot := collector.Snapshot() - if len(snapshot.Errors.ToolErrors) != 2 { - t.Fatalf("tool errors = %+v, want 2 entries", snapshot.Errors.ToolErrors) - } - if len(snapshot.Commands.Executed) != 1 || snapshot.Commands.Executed[0].Succeeded { - t.Fatalf("command facts = %+v, want one failed command fact", snapshot.Commands.Executed) - } - if len(snapshot.SubAgents.Failed) != 1 || snapshot.SubAgents.Failed[0].TaskID != "spawn-1" { - t.Fatalf("subagent failed facts = %+v", snapshot.SubAgents.Failed) - } - if len(snapshot.SubAgents.Completed) != 0 { - t.Fatalf("completed subagent facts should be empty, got %+v", snapshot.SubAgents.Completed) - } -} - -func TestCollectorApplyWriteFileVerificationFacts(t *testing.T) { - collector := NewCollector() - collector.ApplyToolResult(tools.ToolNameFilesystemWriteFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemWriteFile, - IsError: false, - Metadata: map[string]any{ - "path": "verified.txt", - }, - Facts: tools.ToolExecutionFacts{ - WorkspaceWrite: true, - VerificationPerformed: true, - VerificationPassed: true, - VerificationScope: "artifact:verified.txt", - }, - }) - - snapshot := collector.Snapshot() - if len(snapshot.Files.Written) != 1 || snapshot.Files.Written[0].Path != "verified.txt" { - t.Fatalf("written facts = %+v", snapshot.Files.Written) - } - if len(snapshot.Files.Exists) != 1 || snapshot.Files.Exists[0].Path != "verified.txt" { - t.Fatalf("exists facts = %+v", snapshot.Files.Exists) - } - if len(snapshot.Files.ContentMatch) != 1 { - t.Fatalf("content_match facts = %+v", snapshot.Files.ContentMatch) - } - if !snapshot.Files.ContentMatch[0].VerificationPassed || snapshot.Files.ContentMatch[0].Scope != "artifact:verified.txt" { - t.Fatalf("content_match[0] = %+v", snapshot.Files.ContentMatch[0]) - } - if len(snapshot.Verification.Passed) != 1 { - t.Fatalf("verification passed facts = %+v", snapshot.Verification.Passed) - } -} - -func TestCollectorApplyNoopWriteKeepsVerificationFacts(t *testing.T) { - collector := NewCollector() - collector.ApplyToolResult(tools.ToolNameFilesystemWriteFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemWriteFile, - IsError: false, - Metadata: map[string]any{ - "path": "2.txt", - "noop_write": true, - "verification_expected": []string{"2"}, - }, - Facts: tools.ToolExecutionFacts{ - VerificationPerformed: true, - VerificationPassed: true, - VerificationScope: "artifact:2.txt", - }, - }) - - snapshot := collector.Snapshot() - if len(snapshot.Files.Written) != 0 { - t.Fatalf("noop write should not append written fact, got %+v", snapshot.Files.Written) - } - if len(snapshot.Files.Exists) != 1 || snapshot.Files.Exists[0].Path != "2.txt" { - t.Fatalf("exists facts = %+v, want path 2.txt", snapshot.Files.Exists) - } - if len(snapshot.Files.ContentMatch) != 1 { - t.Fatalf("content match facts = %+v, want one noop verification content match", snapshot.Files.ContentMatch) - } - if !snapshot.Files.ContentMatch[0].VerificationPassed || snapshot.Files.ContentMatch[0].Path != "2.txt" { - t.Fatalf("content match fact = %+v", snapshot.Files.ContentMatch[0]) - } - if len(snapshot.Verification.Performed) != 1 || len(snapshot.Verification.Passed) != 1 { - t.Fatalf("verification facts = %+v", snapshot.Verification) - } -} - -func TestCollectorApplyBashWorkspaceWritePathFacts(t *testing.T) { - collector := NewCollector() - collector.ApplyToolResult(tools.ToolNameBash, tools.ToolResult{ - Name: tools.ToolNameBash, - IsError: false, - Metadata: map[string]any{ - "workspace_write_paths": []any{" a.txt ", "a.txt", " b.txt "}, - "exit_code": 0, - "ok": true, - }, - Facts: tools.ToolExecutionFacts{ - WorkspaceWrite: true, - }, - }) - - snapshot := collector.Snapshot() - if len(snapshot.Files.Written) != 2 { - t.Fatalf("bash written facts = %+v, want 2", snapshot.Files.Written) - } - if snapshot.Files.Written[0].Path != "a.txt" || snapshot.Files.Written[1].Path != "b.txt" { - t.Fatalf("bash written paths = %+v, want [a.txt b.txt]", snapshot.Files.Written) - } - if len(snapshot.Files.Exists) != 2 || snapshot.Files.Exists[0].Source != "bash" { - t.Fatalf("bash exists facts = %+v", snapshot.Files.Exists) - } -} - -func TestCollectorApplyGlobAndStringMetadataFacts(t *testing.T) { - collector := NewCollector() - collector.ApplyToolResult(tools.ToolNameFilesystemGlob, tools.ToolResult{ - Name: tools.ToolNameFilesystemGlob, - IsError: false, - Content: " a.txt \n\nb.txt\na.txt", - }) - collector.ApplyToolResult(tools.ToolNameFilesystemWriteFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemWriteFile, - IsError: false, - Metadata: map[string]any{ - "path": "s.txt", - "bytes": "7", - }, - }) - collector.ApplyToolResult(tools.ToolNameSpawnSubAgent, tools.ToolResult{ - Name: tools.ToolNameSpawnSubAgent, - IsError: false, - Content: "Summary: done", - Metadata: map[string]any{ - "task_id": "sa-1", - "role": "reviewer", - "state": "succeeded", - "artifacts": []any{" x.md ", "x.md", " y.md "}, - }, - }) - snapshot := collector.Snapshot() - if len(snapshot.Files.Exists) < 2 { - t.Fatalf("glob exists facts = %+v", snapshot.Files.Exists) - } - if snapshot.Files.Written[0].Bytes != 7 { - t.Fatalf("bytes = %d, want 7", snapshot.Files.Written[0].Bytes) - } - if len(snapshot.SubAgents.Completed) == 0 || len(snapshot.SubAgents.Completed[0].Artifacts) != 2 { - t.Fatalf("subagent artifacts = %+v", snapshot.SubAgents.Completed) - } -} - -func TestCollectorTodoStateFallbackAndErrorDedup(t *testing.T) { - collector := NewCollector() - collector.ApplyToolResult(tools.ToolNameTodoWrite, tools.ToolResult{ - Name: tools.ToolNameTodoWrite, - IsError: false, - Metadata: map[string]any{ - "state_fact": "todo_updated", - "id": "todo-single", - }, - }) - longErr := "" - for i := 0; i < 300; i++ { - longErr += "x" - } - collector.ApplyToolResult(tools.ToolNameBash, tools.ToolResult{ - Name: tools.ToolNameBash, - IsError: true, - ErrorClass: "timeout", - Content: longErr, - }) - collector.ApplyToolResult(tools.ToolNameBash, tools.ToolResult{ - Name: tools.ToolNameBash, - IsError: true, - ErrorClass: "timeout", - Content: "duplicate should dedupe by class", - }) - snapshot := collector.Snapshot() - if len(snapshot.Todos.UpdatedIDs) != 1 || snapshot.Todos.UpdatedIDs[0] != "todo-single" { - t.Fatalf("todo updated ids = %+v", snapshot.Todos.UpdatedIDs) - } - if len(snapshot.Errors.ToolErrors) != 1 { - t.Fatalf("tool errors = %+v", snapshot.Errors.ToolErrors) - } - if len(snapshot.Errors.ToolErrors[0].Content) != 256 { - t.Fatalf("error content length = %d, want 256", len(snapshot.Errors.ToolErrors[0].Content)) - } -} - -func TestSubAgentFactJSONDoesNotContainStateField(t *testing.T) { - payload, err := json.Marshal(SubAgentFact{ - TaskID: "sa-1", - Role: "reviewer", - StopReason: "completed", - Summary: "done", - Artifacts: []string{"a.md"}, - }) - if err != nil { - t.Fatalf("marshal subagent fact failed: %v", err) - } - if strings.Contains(string(payload), "\"state\"") { - t.Fatalf("subagent fact payload should not contain state field, got %s", string(payload)) - } -} - -func TestVerificationFactJSONDoesNotContainPassedField(t *testing.T) { - payload, err := json.Marshal(VerificationFact{ - Tool: "filesystem_read_file", - Scope: "artifact:test.txt", - Reason: "content_match", - }) - if err != nil { - t.Fatalf("marshal verification fact failed: %v", err) - } - if strings.Contains(string(payload), "\"passed\"") { - t.Fatalf("verification fact payload should not contain passed field, got %s", string(payload)) - } -} - -func TestCollectorVerificationFactsGroupedByCollections(t *testing.T) { - collector := NewCollector() - - collector.ApplyToolResult(tools.ToolNameFilesystemReadFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemReadFile, - IsError: false, - Metadata: map[string]any{ - "path": "pass.txt", - "verification_reason": "content_match", - }, - Facts: tools.ToolExecutionFacts{ - VerificationPerformed: true, - VerificationPassed: true, - VerificationScope: "artifact:pass.txt", - }, - }) - collector.ApplyToolResult(tools.ToolNameFilesystemReadFile, tools.ToolResult{ - Name: tools.ToolNameFilesystemReadFile, - IsError: false, - Metadata: map[string]any{ - "path": "fail.txt", - "verification_reason": "content_mismatch", - }, - Facts: tools.ToolExecutionFacts{ - VerificationPerformed: true, - VerificationPassed: false, - VerificationScope: "artifact:fail.txt", - }, - }) - - snapshot := collector.Snapshot() - if len(snapshot.Verification.Performed) != 2 { - t.Fatalf("verification performed facts = %+v, want 2 entries", snapshot.Verification.Performed) - } - if len(snapshot.Verification.Passed) != 1 || snapshot.Verification.Passed[0].Scope != "artifact:pass.txt" { - t.Fatalf("verification passed facts = %+v", snapshot.Verification.Passed) - } - if len(snapshot.Verification.Failed) != 1 || snapshot.Verification.Failed[0].Scope != "artifact:fail.txt" { - t.Fatalf("verification failed facts = %+v", snapshot.Verification.Failed) - } -} diff --git a/internal/runtime/facts/types.go b/internal/runtime/facts/types.go deleted file mode 100644 index 82c3c9983..000000000 --- a/internal/runtime/facts/types.go +++ /dev/null @@ -1,118 +0,0 @@ -package facts - -// RuntimeFacts 描述运行期可验证的统一事实快照。 -type RuntimeFacts struct { - Todos TodoFacts `json:"todos"` - Files FileFacts `json:"files"` - Commands CommandFacts `json:"commands"` - SubAgents SubAgentFacts `json:"subagents"` - Verification VerificationFacts `json:"verification"` - Errors ErrorFacts `json:"errors"` - Progress ProgressFacts `json:"progress"` -} - -// TodoFacts 描述 todo 领域事实。 -type TodoFacts struct { - CreatedIDs []string `json:"created_ids,omitempty"` - UpdatedIDs []string `json:"updated_ids,omitempty"` - CompletedIDs []string `json:"completed_ids,omitempty"` - FailedIDs []string `json:"failed_ids,omitempty"` - ConflictIDs []string `json:"conflict_ids,omitempty"` - OpenRequiredCount int `json:"open_required_count,omitempty"` - CompletedRequiredCount int `json:"completed_required_count,omitempty"` - FailedRequiredCount int `json:"failed_required_count,omitempty"` -} - -// FileFacts 描述文件相关事实。 -type FileFacts struct { - Written []FileWriteFact `json:"written,omitempty"` - Exists []FileExistFact `json:"exists,omitempty"` - ContentMatch []FileContentMatchFact `json:"content_match,omitempty"` -} - -// FileWriteFact 描述一次写入事实。 -type FileWriteFact struct { - Path string `json:"path"` - Bytes int `json:"bytes"` - WorkspaceWrite bool `json:"workspace_write"` - ExpectedContent string `json:"expected_content,omitempty"` -} - -// FileExistFact 描述一次文件存在性事实。 -type FileExistFact struct { - Path string `json:"path"` - Source string `json:"source,omitempty"` -} - -// FileContentMatchFact 描述一次内容匹配事实。 -type FileContentMatchFact struct { - Path string `json:"path"` - Scope string `json:"scope,omitempty"` - ExpectedContains []string `json:"expected_contains,omitempty"` - VerificationPassed bool `json:"verification_passed"` -} - -// CommandFacts 描述命令执行事实。 -type CommandFacts struct { - Executed []CommandFact `json:"executed,omitempty"` -} - -// CommandFact 描述单次命令执行事实。 -type CommandFact struct { - Tool string `json:"tool"` - Command string `json:"command,omitempty"` - ExitCode int `json:"exit_code"` - Succeeded bool `json:"succeeded"` -} - -// SubAgentFacts 按生命周期状态分组保存子代理事实。 -// 状态由所在集合表达:Started / Completed / Failed。 -// SubAgentFact 本身不再携带 State 字段,避免出现双重状态来源。 -type SubAgentFacts struct { - Started []SubAgentFact `json:"started,omitempty"` - Completed []SubAgentFact `json:"completed,omitempty"` - Failed []SubAgentFact `json:"failed,omitempty"` -} - -// SubAgentFact 描述单个子代理任务事实。 -type SubAgentFact struct { - TaskID string `json:"task_id"` - Role string `json:"role,omitempty"` - StopReason string `json:"stop_reason,omitempty"` - Summary string `json:"summary,omitempty"` - Artifacts []string `json:"artifacts,omitempty"` -} - -// VerificationFacts 按验证状态/结果分组保存验证事实。 -// Performed 表示已经执行过验证。 -// Passed / Failed 表示验证结果。 -// VerificationFact 本身不再携带 Passed 字段,避免出现双重状态来源。 -type VerificationFacts struct { - Performed []VerificationFact `json:"performed,omitempty"` - Passed []VerificationFact `json:"passed,omitempty"` - Failed []VerificationFact `json:"failed,omitempty"` -} - -// ErrorFacts 描述运行期工具错误事实,供终态决策识别不可恢复失败。 -type ErrorFacts struct { - ToolErrors []ToolErrorFact `json:"tool_errors,omitempty"` -} - -// ToolErrorFact 描述单次工具错误事实。 -type ToolErrorFact struct { - Tool string `json:"tool,omitempty"` - ErrorClass string `json:"error_class,omitempty"` - Content string `json:"content,omitempty"` -} - -// VerificationFact 描述一次验证尝试事实。 -type VerificationFact struct { - Tool string `json:"tool,omitempty"` - Scope string `json:"scope,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// ProgressFacts 描述事实层的推进度。 -type ProgressFacts struct { - ObservedFactCount int `json:"observed_fact_count"` -} diff --git a/internal/runtime/hook_capability_consistency_test.go b/internal/runtime/hook_capability_consistency_test.go index 09a684706..30905de12 100644 --- a/internal/runtime/hook_capability_consistency_test.go +++ b/internal/runtime/hook_capability_consistency_test.go @@ -14,6 +14,7 @@ func TestRuntimeHookPointUserAllowedMatchesConfigValidation(t *testing.T) { runtimehooks.HookPointBeforeToolCall, runtimehooks.HookPointAfterToolResult, runtimehooks.HookPointBeforeCompletionDecision, + runtimehooks.HookPointAcceptGate, runtimehooks.HookPointBeforePermissionDecision, runtimehooks.HookPointAfterToolFailure, runtimehooks.HookPointSessionStart, diff --git a/internal/runtime/hooks/executor.go b/internal/runtime/hooks/executor.go index 39f71ad17..8f32faef9 100644 --- a/internal/runtime/hooks/executor.go +++ b/internal/runtime/hooks/executor.go @@ -365,6 +365,10 @@ func sanitizeUserHookContext(input HookContext) HookContext { "has_tool_calls": {}, "assistant_role": {}, "detail": {}, + "workspace_changed": {}, + "assistant_text_empty": {}, + "todo_summary": {}, + "recent_tool_summary": {}, } for key, value := range input.Metadata { normalizedKey := strings.ToLower(strings.TrimSpace(key)) diff --git a/internal/runtime/hooks/types.go b/internal/runtime/hooks/types.go index b65a90c0d..e51d5d6f2 100644 --- a/internal/runtime/hooks/types.go +++ b/internal/runtime/hooks/types.go @@ -16,6 +16,8 @@ const ( HookPointAfterToolResult HookPoint = "after_tool_result" // HookPointBeforeCompletionDecision 表示完成决策前挂点。 HookPointBeforeCompletionDecision HookPoint = "before_completion_decision" + // HookPointAcceptGate 表示最终收尾前的用户验收挂点。 + HookPointAcceptGate HookPoint = "accept_gate" // HookPointBeforePermissionDecision 表示权限决策前挂点。 HookPointBeforePermissionDecision HookPoint = "before_permission_decision" // HookPointAfterToolFailure 表示工具失败后挂点。 @@ -48,6 +50,7 @@ var hookPointCapabilities = map[HookPoint]HookPointCapability{ HookPointBeforeToolCall: {CanBlock: true, CanAnnotate: true, CanUpdateInput: false, UserAllowed: true}, HookPointAfterToolResult: {CanBlock: false, CanAnnotate: true, CanUpdateInput: false, UserAllowed: true}, HookPointBeforeCompletionDecision: {CanBlock: false, CanAnnotate: true, CanUpdateInput: false, UserAllowed: true}, + HookPointAcceptGate: {CanBlock: true, CanAnnotate: true, CanUpdateInput: false, UserAllowed: true}, HookPointBeforePermissionDecision: {CanBlock: true, CanAnnotate: true, CanUpdateInput: false, UserAllowed: false}, HookPointAfterToolFailure: {CanBlock: false, CanAnnotate: true, CanUpdateInput: false, UserAllowed: true}, HookPointSessionStart: {CanBlock: false, CanAnnotate: true, CanUpdateInput: false, UserAllowed: true}, @@ -176,7 +179,7 @@ func (s HookSpec) normalizeAndValidate() (HookSpec, error) { s.Kind = HookKindFunction } switch s.Kind { - case HookKindFunction, HookKindHTTP: + case HookKindFunction, HookKindCommand, HookKindHTTP: default: return HookSpec{}, wrapInvalidSpec("kind %q is not supported in current stage", s.Kind) } diff --git a/internal/runtime/hooks/types_test.go b/internal/runtime/hooks/types_test.go index 688160234..cd222fa86 100644 --- a/internal/runtime/hooks/types_test.go +++ b/internal/runtime/hooks/types_test.go @@ -234,6 +234,14 @@ func TestHookPointCapabilities(t *testing.T) { t.Fatal("before_completion_decision should be observe-only in current runtime flow") } + capability, ok = HookPointCapabilities(HookPointAcceptGate) + if !ok { + t.Fatal("expected accept_gate capability to exist") + } + if !capability.CanBlock { + t.Fatal("accept_gate should allow block") + } + if _, exists := HookPointCapabilities(HookPoint("unknown")); exists { t.Fatal("unknown hook point should not have capability") } diff --git a/internal/runtime/planning.go b/internal/runtime/planning.go index 678c0cda8..52bd6d7cf 100644 --- a/internal/runtime/planning.go +++ b/internal/runtime/planning.go @@ -18,11 +18,10 @@ const ( ) type summaryCandidate struct { - Goal string `json:"goal"` - KeySteps []string `json:"key_steps"` - Constraints []string `json:"constraints"` - Verify agentsession.AcceptChecks `json:"verify"` - ActiveTodoIDs []string `json:"active_todo_ids"` + Goal string `json:"goal"` + KeySteps []string `json:"key_steps"` + Constraints []string `json:"constraints"` + ActiveTodoIDs []string `json:"active_todo_ids"` } type planTurnOutput struct { @@ -102,8 +101,7 @@ func planningNeedsFullPlan(state *runState) bool { func summaryViewUsable(summary agentsession.SummaryView) bool { return strings.TrimSpace(summary.Goal) != "" && - len(summary.KeySteps) > 0 && - len(summary.Verify) > 0 + (len(summary.KeySteps) > 0 || len(summary.ActiveTodoIDs) > 0) } func normalizeSummaryCandidate(candidate summaryCandidate) agentsession.SummaryView { @@ -111,7 +109,6 @@ func normalizeSummaryCandidate(candidate summaryCandidate) agentsession.SummaryV Goal: strings.TrimSpace(candidate.Goal), KeySteps: append([]string(nil), candidate.KeySteps...), Constraints: append([]string(nil), candidate.Constraints...), - Verify: candidate.Verify.Clone(), ActiveTodoIDs: append([]string(nil), candidate.ActiveTodoIDs...), } } diff --git a/internal/runtime/planning_test.go b/internal/runtime/planning_test.go index cf7284e7e..fccf8fa3e 100644 --- a/internal/runtime/planning_test.go +++ b/internal/runtime/planning_test.go @@ -222,16 +222,14 @@ func TestBuildPlanArtifact(t *testing.T) { Status: agentsession.PlanStatusDraft, CreatedAt: time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC), Spec: agentsession.PlanSpec{ - Goal: "旧计划", - Steps: []string{"旧步骤"}, - Verify: acceptText("旧验证"), + Goal: "旧计划", + Steps: []string{"旧步骤"}, }, } output := planTurnOutput{ PlanSpec: agentsession.PlanSpec{ - Goal: "新计划", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "新计划", + Steps: []string{"步骤一"}, Todos: []agentsession.TodoItem{ {ID: "todo-1", Content: "待办", Status: agentsession.TodoStatusPending}, }, @@ -239,7 +237,6 @@ func TestBuildPlanArtifact(t *testing.T) { SummaryCandidate: summaryCandidate{ Goal: "新计划", KeySteps: []string{"步骤一"}, - Verify: acceptText("验证一"), ActiveTodoIDs: []string{"todo-1"}, }, } @@ -274,9 +271,8 @@ func TestMarkCurrentPlanCompleted(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "执行当前计划", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "执行当前计划", + Steps: []string{"步骤一"}, }, } if !markCurrentPlanCompleted(&session) { @@ -307,9 +303,8 @@ func TestPlanningNeedsFullPlan(t *testing.T) { Revision: 2, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "Use full plan when alignment is pending", - Steps: []string{"align plan"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "Use full plan when alignment is pending", + Steps: []string{"align plan"}, Todos: []agentsession.TodoItem{ {ID: "todo-1", Content: "align plan", Status: agentsession.TodoStatusPending}, }, @@ -317,7 +312,6 @@ func TestPlanningNeedsFullPlan(t *testing.T) { Summary: agentsession.SummaryView{ Goal: "Use full plan when alignment is pending", KeySteps: []string{"align plan"}, - Verify: acceptText("go test ./internal/runtime"), ActiveTodoIDs: []string{"todo-1"}, }, } @@ -362,9 +356,8 @@ func TestApproveCurrentPlan(t *testing.T) { Revision: 3, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "批准当前计划", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "批准当前计划", + Steps: []string{"步骤一"}, }, } if err := approveCurrentPlan(&session, "plan-approve", 3); err != nil { @@ -387,9 +380,8 @@ func TestRememberFullPlanRevisionClearsAlignmentFlags(t *testing.T) { Revision: 2, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "完成全文对齐", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "完成全文对齐", + Steps: []string{"步骤一"}, }, } session.PlanApprovalPendingFullAlign = true @@ -425,9 +417,8 @@ func TestMarkCurrentPlanRestorePendingAndContextDirty(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "restore full plan", - Steps: []string{"step one"}, - Verify: acceptText("verify one"), + Goal: "restore full plan", + Steps: []string{"step one"}, }, } if !markCurrentPlanRestorePending(&session) { @@ -481,9 +472,8 @@ func TestApproveCurrentPlanValidationErrors(t *testing.T) { Revision: 2, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "审批校验", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "审批校验", + Steps: []string{"步骤一"}, }, } diff --git a/internal/runtime/repo_hooks.go b/internal/runtime/repo_hooks.go index 8f9b5e3a3..9fb5140cd 100644 --- a/internal/runtime/repo_hooks.go +++ b/internal/runtime/repo_hooks.go @@ -23,6 +23,7 @@ const ( repoHooksTrustStoreVersion = 1 repoHookScopeValue = "repo" repoHookKindBuiltIn = "builtin" + repoHookKindCommand = "command" repoHookModeSync = "sync" repoHookFailurePolicyWarnOnly = "warn_only" repoHookFailurePolicyFailOpen = "fail_open" @@ -324,6 +325,7 @@ func validateRepoHookItem(item config.RuntimeHookItemConfig) error { case string(runtimehooks.HookPointBeforeToolCall), string(runtimehooks.HookPointAfterToolResult), string(runtimehooks.HookPointBeforeCompletionDecision), + string(runtimehooks.HookPointAcceptGate), string(runtimehooks.HookPointBeforePermissionDecision), string(runtimehooks.HookPointAfterToolFailure), string(runtimehooks.HookPointSessionStart), @@ -342,10 +344,11 @@ func validateRepoHookItem(item config.RuntimeHookItemConfig) error { if strings.ToLower(strings.TrimSpace(item.Scope)) != repoHookScopeValue { return fmt.Errorf("scope %q is not supported", item.Scope) } - if normalizedKind := strings.ToLower(strings.TrimSpace(item.Kind)); normalizedKind != repoHookKindBuiltIn { + if normalizedKind := strings.ToLower(strings.TrimSpace(item.Kind)); normalizedKind != repoHookKindBuiltIn && + normalizedKind != repoHookKindCommand { if _, external := repoHookExternalKinds[normalizedKind]; external { return fmt.Errorf( - "external hook kind %q is not supported in P6-lite; only builtin hooks are enabled", + "external hook kind %q is not supported in current stage; only builtin/command hooks are enabled", item.Kind, ) } @@ -362,14 +365,21 @@ func validateRepoHookItem(item config.RuntimeHookItemConfig) error { default: return fmt.Errorf("failure_policy %q is invalid", item.FailurePolicy) } - handler := strings.ToLower(strings.TrimSpace(item.Handler)) - switch handler { - case "require_file_exists", "warn_on_tool_call", "add_context_note": - default: - return fmt.Errorf("handler %q is not supported", item.Handler) - } - if handler == "warn_on_tool_call" && !runtimeHasWarnOnToolCallTargets(item.Params) { - return fmt.Errorf("handler %q requires params.tool_name or params.tool_names", item.Handler) + switch strings.ToLower(strings.TrimSpace(item.Kind)) { + case repoHookKindBuiltIn: + handler := strings.ToLower(strings.TrimSpace(item.Handler)) + switch handler { + case "require_file_exists", "warn_on_tool_call", "add_context_note": + default: + return fmt.Errorf("handler %q is not supported", item.Handler) + } + if handler == "warn_on_tool_call" && !runtimeHasWarnOnToolCallTargets(item.Params) { + return fmt.Errorf("handler %q requires params.tool_name or params.tool_names", item.Handler) + } + case repoHookKindCommand: + if strings.TrimSpace(readHookParamString(item.Params, "command")) == "" { + return fmt.Errorf("kind command requires params.command") + } } return nil } diff --git a/internal/runtime/repo_hooks_test.go b/internal/runtime/repo_hooks_test.go index b7243c731..bb50f7f1c 100644 --- a/internal/runtime/repo_hooks_test.go +++ b/internal/runtime/repo_hooks_test.go @@ -380,10 +380,10 @@ func TestBuildRepoHookExecutorRejectsExternalKindAndDoesNotRegister(t *testing.T content := ` hooks: items: - - id: repo-external-command + - id: repo-external-prompt point: before_tool_call scope: repo - kind: command + kind: prompt mode: sync handler: warn_on_tool_call params: @@ -412,8 +412,8 @@ hooks: if err == nil { t.Fatal("expected external kind in repo hook config to be rejected") } - if !strings.Contains(err.Error(), "not supported in P6-lite") { - t.Fatalf("error=%q, want contains not supported in P6-lite", err.Error()) + if !strings.Contains(err.Error(), "not supported in current stage") { + t.Fatalf("error=%q, want contains not supported in current stage", err.Error()) } if exec != nil { t.Fatalf("unexpected repo executor after rejection: %T", exec) @@ -579,7 +579,7 @@ func TestValidateRepoHookItemRejectsExternalKindsWithP6LiteMessage(t *testing.T) FailurePolicy: "warn_only", Params: map[string]any{"note": "x"}, } - externalKinds := []string{"command", "http", "prompt", "agent"} + externalKinds := []string{"http", "prompt", "agent"} for _, kind := range externalKinds { kind := kind t.Run(kind, func(t *testing.T) { @@ -589,8 +589,8 @@ func TestValidateRepoHookItemRejectsExternalKindsWithP6LiteMessage(t *testing.T) if err == nil { t.Fatalf("expected external kind %q to be rejected", kind) } - if !strings.Contains(err.Error(), "not supported in P6-lite") { - t.Fatalf("error=%q, want contains not supported in P6-lite", err.Error()) + if !strings.Contains(err.Error(), "not supported in current stage") { + t.Fatalf("error=%q, want contains not supported in current stage", err.Error()) } }) } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index f4b7c0296..43b89025c 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -29,9 +29,10 @@ import ( var selfHealingRepeatReminder = promptasset.RepeatCycleReminder() const ( - usageSourceObserved = "observed" - usageSourceEstimated = "estimated" - usageSourceUnknown = "unknown" + usageSourceObserved = "observed" + usageSourceEstimated = "estimated" + usageSourceUnknown = "unknown" + maxAcceptanceContinues = 3 ) // computeToolSignature 计算单轮执行的工具签名,用于循环检测。 @@ -103,9 +104,6 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { perEditIDs = append(perEditIDs, checkpoint.PerEditCheckpointIDFromRef(r.CodeCheckpointRef)) } } - if len(perEditIDs) > 0 { - _ = s.perEditStore.RunEndCapture(runEndCtx, perEditIDs) - } } diffStr, files, _ := s.perEditStore.RunAggregateDiff(runEndCtx, perEditIDs, nil) var changedFiles []FileDiffEntry @@ -387,25 +385,38 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { } assistantText := strings.TrimSpace(partsrender.RenderDisplayParts(turnOutput.assistant.Parts)) - hasThinking := len(turnOutput.assistant.ThinkingMetadata) > 0 - - if assistantText == "" { - if hasThinking { - break turnAttempt - } - state.markTerminalDecision( - controlplane.TerminalStatusIncomplete, - controlplane.StopReasonEmptyResponse, - "assistant returned empty text without tool calls", - ) - s.emitRunScoped(ctx, EventAgentDone, &state, turnOutput.assistant) - return nil - } if err := s.setBaseRunState(ctx, &state, controlplane.RunStateVerify); err != nil { return s.handleRunError(err) } s.updateResumeCheckpoint(ctx, &state, "verify", "completed") + + if state.shouldRunAcceptGateHook() { + hookOutput := s.runHookPoint(ctx, &state, runtimehooks.HookPointAcceptGate, runtimehooks.HookContext{ + Metadata: buildAcceptGateHookMetadata(&state, assistantText), + }) + if hookOutput.Blocked { + reason := findHookBlockMessage(hookOutput) + s.emitRunScoped(ctx, EventHookBlocked, &state, HookBlockedPayload{ + HookID: strings.TrimSpace(hookOutput.BlockedBy), + Source: string(findHookBlockSource(hookOutput)), + Point: string(runtimehooks.HookPointAcceptGate), + Reason: reason, + Enforced: true, + }) + report := continuedAcceptGateReport(reason) + s.emitAcceptGateReport(&state, report) + if s.handleAcceptanceContinue(ctx, &state, report) { + break turnAttempt + } + if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, turnOutput.assistant); err != nil { + return s.handleRunError(err) + } + s.emitRunScoped(ctx, EventAgentDone, &state, turnOutput.assistant) + return nil + } + } + report := s.evaluateAcceptGate(ctx, &state, turnOutput.assistant) s.emitAcceptGateReport(&state, report) @@ -428,6 +439,16 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { return nil } + if report.Outcome == acceptgate.OutcomeContinue { + if s.handleAcceptanceContinue(ctx, &state, report) { + break turnAttempt + } + if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, turnOutput.assistant); err != nil { + return s.handleRunError(err) + } + s.emitRunScoped(ctx, EventAgentDone, &state, turnOutput.assistant) + return nil + } if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, turnOutput.assistant); err != nil { return s.handleRunError(err) } @@ -457,6 +478,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { state.hasRunWorkspaceWrite = true state.mu.Unlock() } + state.recordRecentToolSummary(summary) state.mu.Lock() state.completion = applyToolExecutionCompletion(state.completion, summary) diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index aaf61de24..240a40d27 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -813,9 +813,11 @@ func TestExecuteAssistantToolCallsEmitsResultsWhenDoneAndPersistsInCallOrder(t * var mu sync.Mutex active := 0 maxActive := 0 + var enterBarrier sync.WaitGroup slowStarted := make(chan struct{}) releaseSlow := make(chan struct{}) var slowStartedOnce sync.Once + enterBarrier.Add(2) manager := &stubToolManager{ executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { mu.Lock() @@ -824,6 +826,9 @@ func TestExecuteAssistantToolCallsEmitsResultsWhenDoneAndPersistsInCallOrder(t * maxActive = active } mu.Unlock() + // Wait for both tools to enter executeFn before proceeding + enterBarrier.Done() + enterBarrier.Wait() if input.Name == "tool_slow" { slowStartedOnce.Do(func() { close(slowStarted) }) select { diff --git a/internal/runtime/runtime_snapshot.go b/internal/runtime/runtime_snapshot.go index e76a7c69a..0b751af15 100644 --- a/internal/runtime/runtime_snapshot.go +++ b/internal/runtime/runtime_snapshot.go @@ -5,29 +5,22 @@ import ( "strings" "time" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) -// RuntimeSnapshot 描述当前运行态的统一可观测快照,供 TUI/Gateway/Desktop 实时展示。 +// RuntimeSnapshot 描述当前运行态的统一快照,供 TUI/Gateway/Desktop 实时展示。 type RuntimeSnapshot struct { RunID string `json:"run_id"` SessionID string `json:"session_id"` Phase string `json:"phase,omitempty"` UpdatedAt time.Time `json:"updated_at"` Todos TodoSnapshot `json:"todos"` - Facts FactsSnapshot `json:"facts"` Decision DecisionSnapshot `json:"decision,omitempty"` SubAgents SubAgentSnapshot `json:"subagents,omitempty"` // PendingUserQuestion 表示当前 run 是否存在待回答 ask_user 问题。 PendingUserQuestion *UserQuestionRequestedPayload `json:"pending_user_question,omitempty"` } -// FactsSnapshot 是 runtime facts 的传输快照。 -type FactsSnapshot struct { - RuntimeFacts runtimefacts.RuntimeFacts `json:"runtime_facts"` -} - // DecisionSnapshot 是终态裁决快照。 type DecisionSnapshot struct { Status string `json:"status,omitempty"` @@ -36,7 +29,7 @@ type DecisionSnapshot struct { Details []string `json:"details,omitempty"` } -// SubAgentSnapshot 汇总子代理事实状态,避免客户端自行遍历事实结构。 +// SubAgentSnapshot 汇总当前 run 内由 spawn_subagent 产生的子代理结果。 type SubAgentSnapshot struct { StartedCount int `json:"started_count"` CompletedCount int `json:"completed_count"` @@ -49,13 +42,7 @@ type RuntimeSnapshotUpdatedPayload struct { Snapshot RuntimeSnapshot `json:"snapshot"` } -// FactsUpdatedPayload 表示事实层快照更新事件。 -type FactsUpdatedPayload struct { - Reason string `json:"reason,omitempty"` - Facts FactsSnapshot `json:"facts"` -} - -// SubAgentSnapshotUpdatedPayload 表示子代理事实快照更新事件。 +// SubAgentSnapshotUpdatedPayload 表示子代理聚合快照更新事件。 type SubAgentSnapshotUpdatedPayload struct { Reason string `json:"reason,omitempty"` SubAgent SubAgentSnapshot `json:"subagent"` @@ -70,13 +57,6 @@ func buildRuntimeSnapshot(state *runState) RuntimeSnapshot { state.mu.Lock() defer state.mu.Unlock() - todos := cloneTodosForPersistence(state.session.Todos) - todoSnapshot := buildTodoSnapshotFromItems(todos) - factsSnapshot := runtimefacts.RuntimeFacts{} - if state.factsCollector != nil { - factsSnapshot = state.factsCollector.Snapshot() - } - decisionSnapshot := DecisionSnapshot{} if state.terminalSet || state.terminalStatus != "" || state.terminalStopReason != "" { decisionSnapshot = DecisionSnapshot{ @@ -85,24 +65,16 @@ func buildRuntimeSnapshot(state *runState) RuntimeSnapshot { Summary: strings.TrimSpace(state.terminalStopDetail), } } - pendingUserQuestion := clonePendingUserQuestion(state.pendingUserQuestion) return RuntimeSnapshot{ - RunID: strings.TrimSpace(state.runID), - SessionID: strings.TrimSpace(state.session.ID), - Phase: strings.TrimSpace(string(state.lifecycle)), - UpdatedAt: time.Now(), - Todos: todoSnapshot, - Facts: FactsSnapshot{ - RuntimeFacts: factsSnapshot, - }, - Decision: decisionSnapshot, - SubAgents: SubAgentSnapshot{ - StartedCount: len(factsSnapshot.SubAgents.Started), - CompletedCount: len(factsSnapshot.SubAgents.Completed), - FailedCount: len(factsSnapshot.SubAgents.Failed), - }, - PendingUserQuestion: pendingUserQuestion, + RunID: strings.TrimSpace(state.runID), + SessionID: strings.TrimSpace(state.session.ID), + Phase: strings.TrimSpace(string(state.lifecycle)), + UpdatedAt: time.Now(), + Todos: buildTodoSnapshotFromItems(cloneTodosForPersistence(state.session.Todos)), + Decision: decisionSnapshot, + SubAgents: state.subAgentSnapshot.snapshot(), + PendingUserQuestion: clonePendingUserQuestion(state.pendingUserQuestion), } } @@ -119,43 +91,17 @@ func (s *Service) emitRuntimeSnapshotUpdated(ctx context.Context, state *runStat }) } -// emitFactsUpdated 发出 facts_updated 事件,供 UI 实时消费事实增量状态。 -func (s *Service) emitFactsUpdated(state *runState, reason string) { - if s == nil || state == nil { - return - } - state.mu.Lock() - factsSnapshot := runtimefacts.RuntimeFacts{} - if state.factsCollector != nil { - factsSnapshot = state.factsCollector.Snapshot() - } - state.mu.Unlock() - s.emitRunScopedOptional(EventFactsUpdated, state, FactsUpdatedPayload{ - Reason: strings.TrimSpace(reason), - Facts: FactsSnapshot{ - RuntimeFacts: factsSnapshot, - }, - }) -} - -// emitSubAgentSnapshotUpdated 发出 subagent_snapshot_updated 事件,供 UI 聚合展示子代理状态。 +// emitSubAgentSnapshotUpdated 发出独立的子代理聚合快照事件,供 UI 展示当前 run 总览。 func (s *Service) emitSubAgentSnapshotUpdated(state *runState, reason string) { if s == nil || state == nil { return } state.mu.Lock() - factsSnapshot := runtimefacts.RuntimeFacts{} - if state.factsCollector != nil { - factsSnapshot = state.factsCollector.Snapshot() - } + snapshot := state.subAgentSnapshot.snapshot() state.mu.Unlock() s.emitRunScopedOptional(EventSubAgentSnapshotUpdated, state, SubAgentSnapshotUpdatedPayload{ - Reason: strings.TrimSpace(reason), - SubAgent: SubAgentSnapshot{ - StartedCount: len(factsSnapshot.SubAgents.Started), - CompletedCount: len(factsSnapshot.SubAgents.Completed), - FailedCount: len(factsSnapshot.SubAgents.Failed), - }, + Reason: strings.TrimSpace(reason), + SubAgent: snapshot, }) } @@ -195,13 +141,9 @@ func (s *Service) GetRuntimeSnapshot(ctx context.Context, sessionID string) (Run return RuntimeSnapshot{}, err } snapshot := RuntimeSnapshot{ - SessionID: normalizedSessionID, - Phase: "", - UpdatedAt: session.UpdatedAt, - Todos: buildTodoSnapshotFromItems(session.ListTodos()), - Facts: FactsSnapshot{ - RuntimeFacts: runtimefacts.RuntimeFacts{}, - }, + SessionID: normalizedSessionID, + UpdatedAt: session.UpdatedAt, + Todos: buildTodoSnapshotFromItems(session.ListTodos()), PendingUserQuestion: nil, } s.cacheRuntimeSnapshot(snapshot) diff --git a/internal/runtime/runtime_snapshot_test.go b/internal/runtime/runtime_snapshot_test.go index 390b88513..17c748e1b 100644 --- a/internal/runtime/runtime_snapshot_test.go +++ b/internal/runtime/runtime_snapshot_test.go @@ -6,44 +6,9 @@ import ( "testing" "time" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) -func TestEmitSubAgentSnapshotUpdatedEmitsAggregatedCounts(t *testing.T) { - t.Parallel() - - service := &Service{events: make(chan RuntimeEvent, 2)} - state := newRunState("run-subagent-snapshot", newRuntimeSession("session-subagent-snapshot")) - collector := runtimefacts.NewCollector() - collector.ApplySubAgentStarted(runtimefacts.SubAgentFact{TaskID: "agent-1", Role: "task"}) - collector.ApplySubAgentFinished(runtimefacts.SubAgentFact{TaskID: "agent-1", Artifacts: []string{"a.txt"}}, true) - collector.ApplySubAgentStarted(runtimefacts.SubAgentFact{TaskID: "agent-2", Role: "task"}) - collector.ApplySubAgentFinished(runtimefacts.SubAgentFact{TaskID: "agent-2", StopReason: "tool_error"}, false) - state.factsCollector = collector - - service.emitSubAgentSnapshotUpdated(&state, "tool_result") - - select { - case evt := <-service.events: - if evt.Type != EventSubAgentSnapshotUpdated { - t.Fatalf("event type = %q, want %q", evt.Type, EventSubAgentSnapshotUpdated) - } - payload, ok := evt.Payload.(SubAgentSnapshotUpdatedPayload) - if !ok { - t.Fatalf("payload type = %T, want SubAgentSnapshotUpdatedPayload", evt.Payload) - } - if payload.Reason != "tool_result" { - t.Fatalf("reason = %q, want tool_result", payload.Reason) - } - if payload.SubAgent.StartedCount != 2 || payload.SubAgent.CompletedCount != 1 || payload.SubAgent.FailedCount != 1 { - t.Fatalf("unexpected subagent counts: %+v", payload.SubAgent) - } - default: - t.Fatal("expected subagent snapshot event") - } -} - func TestGetRuntimeSnapshotBranches(t *testing.T) { t.Parallel() @@ -79,7 +44,20 @@ func TestGetRuntimeSnapshotBranches(t *testing.T) { if got.SessionID != session.ID { t.Fatalf("session id = %q, want %q", got.SessionID, session.ID) } - if got.Facts.RuntimeFacts.Progress.ObservedFactCount != 0 { - t.Fatalf("unexpected facts snapshot: %+v", got.Facts.RuntimeFacts) + if got.Todos.Summary.Total != 0 { + t.Fatalf("unexpected todos snapshot: %+v", got.Todos) + } +} + +func TestBuildRuntimeSnapshotCarriesIndependentSubAgentCounts(t *testing.T) { + t.Parallel() + + state := newRunState("run-subagent", newRuntimeSession("session-subagent")) + state.subAgentSnapshot.started = map[string]struct{}{"task-1:coder": {}} + state.subAgentSnapshot.completed = map[string]struct{}{"task-1": {}} + + got := buildRuntimeSnapshot(&state) + if got.SubAgents.StartedCount != 1 || got.SubAgents.CompletedCount != 1 || got.SubAgents.FailedCount != 0 { + t.Fatalf("subagent snapshot = %+v", got.SubAgents) } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f7273a51d..cd0e218ee 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4044,14 +4044,12 @@ func TestServiceRunPlanModeKeepsExistingPlanWhenPlanSpecIsInvalid(t *testing.T) Revision: 2, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "Keep previous plan", - Steps: []string{"existing step"}, - Verify: acceptText("existing verify"), + Goal: "Keep previous plan", + Steps: []string{"existing step"}, }, Summary: agentsession.SummaryView{ Goal: "Keep previous plan", KeySteps: []string{"existing step"}, - Verify: acceptText("existing verify"), }, } seed.LastFullPlanRevision = 2 @@ -4271,9 +4269,8 @@ func TestServiceRunPlanModeUsesSummaryViewForAlignedPlanTurn(t *testing.T) { Revision: 2, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "Keep planning aligned", - Steps: []string{"summarize current plan"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "Keep planning aligned", + Steps: []string{"summarize current plan"}, Todos: []agentsession.TodoItem{ {ID: "todo-aligned", Content: "summarize current plan", Status: agentsession.TodoStatusPending}, }, @@ -4281,7 +4278,6 @@ func TestServiceRunPlanModeUsesSummaryViewForAlignedPlanTurn(t *testing.T) { Summary: agentsession.SummaryView{ Goal: "Keep planning aligned", KeySteps: []string{"summarize current plan"}, - Verify: acceptText("go test ./internal/runtime"), ActiveTodoIDs: []string{"todo-aligned"}, }, } @@ -4347,9 +4343,8 @@ func TestServiceRunBuildModeInjectsFullPlanForUnalignedExistingPlan(t *testing.T Revision: 2, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "Resume build execution", - Steps: []string{"resume implementation"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "Resume build execution", + Steps: []string{"resume implementation"}, Todos: []agentsession.TodoItem{ {ID: "todo-restored", Content: "resume implementation", Status: agentsession.TodoStatusPending}, }, @@ -4357,7 +4352,6 @@ func TestServiceRunBuildModeInjectsFullPlanForUnalignedExistingPlan(t *testing.T Summary: agentsession.SummaryView{ Goal: "Resume build execution", KeySteps: []string{"resume implementation"}, - Verify: acceptText("go test ./internal/runtime"), ActiveTodoIDs: []string{"todo-restored"}, }, } @@ -4413,9 +4407,8 @@ func TestServiceRunBuildModeUsesSummaryViewForAlignedExecuteTurn(t *testing.T) { Revision: 3, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "Execute aligned build", - Steps: []string{"continue implementation"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "Execute aligned build", + Steps: []string{"continue implementation"}, Todos: []agentsession.TodoItem{ {ID: "todo-build-aligned", Content: "continue implementation", Status: agentsession.TodoStatusPending}, }, @@ -4423,7 +4416,6 @@ func TestServiceRunBuildModeUsesSummaryViewForAlignedExecuteTurn(t *testing.T) { Summary: agentsession.SummaryView{ Goal: "Execute aligned build", KeySteps: []string{"continue implementation"}, - Verify: acceptText("go test ./internal/runtime"), ActiveTodoIDs: []string{"todo-build-aligned"}, }, } @@ -4475,9 +4467,8 @@ func TestServiceRunBuildModeInjectsFullPlanWhenSummaryIsUnusable(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "Follow full plan when summary is missing", - Steps: []string{"review whole plan"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "Follow full plan when summary is missing", + Steps: []string{"review whole plan"}, Todos: []agentsession.TodoItem{ {ID: "todo-full-fallback", Content: "review whole plan", Status: agentsession.TodoStatusPending}, }, @@ -4532,14 +4523,12 @@ func TestServiceApproveCurrentPlanTriggersOneFullPlanAlignment(t *testing.T) { Revision: 4, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "批准并执行当前计划", - Steps: []string{"继续实现"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "批准并执行当前计划", + Steps: []string{"继续实现"}, }, Summary: agentsession.SummaryView{ Goal: "批准并执行当前计划", KeySteps: []string{"继续实现"}, - Verify: acceptText("go test ./internal/runtime"), }, } seed.LastFullPlanRevision = 4 @@ -4641,9 +4630,8 @@ func TestServiceApproveCurrentPlanTrimsSessionID(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "trim session id before load", - Steps: []string{"step one"}, - Verify: acceptText("verify one"), + Goal: "trim session id before load", + Steps: []string{"step one"}, }, } if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { @@ -4681,14 +4669,12 @@ func TestServiceRunBuildModeIgnoresPlanningJSON(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "保持旧计划不被覆盖", - Steps: []string{"旧步骤"}, - Verify: acceptText("旧验证"), + Goal: "保持旧计划不被覆盖", + Steps: []string{"旧步骤"}, }, Summary: agentsession.SummaryView{ Goal: "保持旧计划不被覆盖", KeySteps: []string{"旧步骤"}, - Verify: acceptText("旧验证"), }, } seed.LastFullPlanRevision = 1 @@ -4752,14 +4738,12 @@ func TestServiceRunCompletedPlanRequestsOneFinalFullReview(t *testing.T) { Revision: 2, Status: agentsession.PlanStatusDraft, Spec: agentsession.PlanSpec{ - Goal: "完成计划后仍需一次全文确认", - Steps: []string{"收尾"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "完成计划后仍需一次全文确认", + Steps: []string{"收尾"}, }, Summary: agentsession.SummaryView{ Goal: "完成计划后仍需一次全文确认", KeySteps: []string{"收尾"}, - Verify: acceptText("go test ./internal/runtime"), }, } seed.LastFullPlanRevision = 2 @@ -4840,14 +4824,12 @@ func TestServiceCompactMarksPlanContextDirty(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "compact 后重对齐计划", - Steps: []string{"压缩历史"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "compact 后重对齐计划", + Steps: []string{"压缩历史"}, }, Summary: agentsession.SummaryView{ Goal: "compact 后重对齐计划", KeySteps: []string{"压缩历史"}, - Verify: acceptText("go test ./internal/runtime"), }, } if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(session)); err != nil { @@ -4891,14 +4873,12 @@ func TestServiceRunCompactedSessionRequestsRestoreAlignment(t *testing.T) { Revision: 1, Status: agentsession.PlanStatusApproved, Spec: agentsession.PlanSpec{ - Goal: "compact 恢复后重新对齐计划", - Steps: []string{"继续执行"}, - Verify: acceptText("go test ./internal/runtime"), + Goal: "compact 恢复后重新对齐计划", + Steps: []string{"继续执行"}, }, Summary: agentsession.SummaryView{ Goal: "compact 恢复后重新对齐计划", KeySteps: []string{"继续执行"}, - Verify: acceptText("go test ./internal/runtime"), }, } seed.LastFullPlanRevision = 1 diff --git a/internal/runtime/state.go b/internal/runtime/state.go index 65b529454..4ab2420ff 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -6,7 +6,6 @@ import ( providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/controlplane" - runtimefacts "neo-code/internal/runtime/facts" "neo-code/internal/security" agentsession "neo-code/internal/session" ) @@ -38,8 +37,8 @@ type runState struct { maxTurnsLimit int userGoal string pendingSystemReminder string + acceptanceContinueCount int toolTimeoutBackoff map[string]int - factsCollector *runtimefacts.Collector terminalStatus controlplane.TerminalStatus terminalStopReason controlplane.StopReason terminalStopDetail string @@ -50,6 +49,8 @@ type runState struct { lastEndOfTurnCheckpointID string runCheckpointID string hasRunWorkspaceWrite bool + recentToolSummary []hookToolSummaryItem + subAgentSnapshot subAgentSnapshotState hookAnnotations []string hookNotifications []queuedHookNotification hookNotificationSeen map[string]time.Time @@ -68,7 +69,6 @@ func newRunState(runID string, session agentsession.Session) runState { nextAttemptSeq: 1, completion: controlplane.CompletionState{TodoOnlyTaskCandidate: true}, reportedMissingSkills: make(map[string]struct{}), - factsCollector: runtimefacts.NewCollector(), hookNotificationSeen: make(map[string]time.Time), toolTimeoutBackoff: make(map[string]int), } diff --git a/internal/runtime/subagent_snapshot.go b/internal/runtime/subagent_snapshot.go new file mode 100644 index 000000000..cd918c33d --- /dev/null +++ b/internal/runtime/subagent_snapshot.go @@ -0,0 +1,78 @@ +package runtime + +import ( + "strings" + + "neo-code/internal/subagent" + "neo-code/internal/tools" +) + +// subAgentSnapshotState 维护当前 run 内由 spawn_subagent 产生的聚合计数。 +type subAgentSnapshotState struct { + started map[string]struct{} + completed map[string]struct{} + failed map[string]struct{} +} + +// applySpawnResult 吸收一次 spawn_subagent 结果,并返回聚合计数是否发生变化。 +func (s *subAgentSnapshotState) applySpawnResult(result tools.ToolResult) bool { + if s == nil { + return false + } + taskID := metadataString(result.Metadata, "task_id") + if taskID == "" { + return false + } + role := metadataString(result.Metadata, "role") + startKey := taskID + ":" + role + changed := addSubAgentSnapshotKey(&s.started, startKey) + + state := subagent.State(metadataString(result.Metadata, "state")) + switch state { + case subagent.StateSucceeded: + changed = addSubAgentSnapshotKey(&s.completed, taskID) || changed + case subagent.StateFailed, subagent.StateCanceled: + changed = addSubAgentSnapshotKey(&s.failed, taskID+":"+metadataString(result.Metadata, "stop_reason")) || changed + } + return changed +} + +// snapshot 把内部集合压缩为对外稳定的聚合计数。 +func (s *subAgentSnapshotState) snapshot() SubAgentSnapshot { + if s == nil { + return SubAgentSnapshot{} + } + return SubAgentSnapshot{ + StartedCount: len(s.started), + CompletedCount: len(s.completed), + FailedCount: len(s.failed), + } +} + +// addSubAgentSnapshotKey 把唯一键写入集合,并返回本次是否新增。 +func addSubAgentSnapshotKey(target *map[string]struct{}, key string) bool { + if target == nil { + return false + } + key = strings.TrimSpace(key) + if key == "" { + return false + } + if *target == nil { + *target = make(map[string]struct{}) + } + if _, exists := (*target)[key]; exists { + return false + } + (*target)[key] = struct{}{} + return true +} + +// metadataString 读取工具结果 metadata 中的字符串字段。 +func metadataString(metadata map[string]any, key string) string { + if len(metadata) == 0 { + return "" + } + value, _ := metadata[key].(string) + return strings.TrimSpace(value) +} diff --git a/internal/runtime/subagent_snapshot_test.go b/internal/runtime/subagent_snapshot_test.go new file mode 100644 index 000000000..312362586 --- /dev/null +++ b/internal/runtime/subagent_snapshot_test.go @@ -0,0 +1,76 @@ +package runtime + +import ( + "context" + "testing" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/subagent" + "neo-code/internal/tools" +) + +func TestSubAgentSnapshotStateApplySpawnResult(t *testing.T) { + t.Parallel() + + var state subAgentSnapshotState + if changed := state.applySpawnResult(tools.ToolResult{}); changed { + t.Fatal("empty result should not update snapshot state") + } + + completed := tools.ToolResult{Metadata: map[string]any{ + "task_id": "task-1", + "role": "coder", + "state": string(subagent.StateSucceeded), + }} + if changed := state.applySpawnResult(completed); !changed { + t.Fatal("first completed result should update snapshot state") + } + if changed := state.applySpawnResult(completed); changed { + t.Fatal("duplicate completed result should be deduplicated") + } + + failed := tools.ToolResult{Metadata: map[string]any{ + "task_id": "task-2", + "role": "reviewer", + "state": string(subagent.StateFailed), + "stop_reason": "error", + }} + if changed := state.applySpawnResult(failed); !changed { + t.Fatal("failed result should update snapshot state") + } + + got := state.snapshot() + if got.StartedCount != 2 || got.CompletedCount != 1 || got.FailedCount != 1 { + t.Fatalf("snapshot = %+v, want started=2 completed=1 failed=1", got) + } +} + +func TestEmitSubAgentSnapshotEventsUpdatesSnapshotAndEmitsEvents(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 4), + runtimeSnapshots: make(map[string]RuntimeSnapshot), + } + state := newRunState("run-subagent", newRuntimeSession("session-subagent")) + result := tools.ToolResult{Metadata: map[string]any{ + "task_id": "task-1", + "role": "coder", + "state": string(subagent.StateSucceeded), + }} + + service.emitSubAgentSnapshotEvents(context.Background(), &state, providertypes.ToolCall{ + Name: tools.ToolNameSpawnSubAgent, + }, result) + + snapshot := buildRuntimeSnapshot(&state) + if snapshot.SubAgents.StartedCount != 1 || snapshot.SubAgents.CompletedCount != 1 || snapshot.SubAgents.FailedCount != 0 { + t.Fatalf("runtime snapshot subagents = %+v", snapshot.SubAgents) + } + + first := <-service.events + second := <-service.events + if first.Type != EventSubAgentSnapshotUpdated || second.Type != EventRuntimeSnapshotUpdated { + t.Fatalf("event sequence = [%s %s], want [%s %s]", first.Type, second.Type, EventSubAgentSnapshotUpdated, EventRuntimeSnapshotUpdated) + } +} diff --git a/internal/runtime/todo_run_boundary.go b/internal/runtime/todo_run_boundary.go index 5df46f462..cfe575c2f 100644 --- a/internal/runtime/todo_run_boundary.go +++ b/internal/runtime/todo_run_boundary.go @@ -5,7 +5,6 @@ import ( "reflect" "time" - runtimefacts "neo-code/internal/runtime/facts" agentsession "neo-code/internal/session" ) @@ -24,9 +23,6 @@ func (s *Service) resetTodosForUserRun(ctx context.Context, state *runState) err } state.session.Todos = nextTodos state.session.UpdatedAt = time.Now() - if state.factsCollector != nil { - state.factsCollector.ApplyTodoSnapshot(todoSummaryLikeForItems(nextTodos)) - } sessionSnapshot := cloneSessionForPersistence(state.session) state.mu.Unlock() @@ -62,23 +58,3 @@ func shouldResetTodosForUserRun(session agentsession.Session) bool { return true } } - -// todoSummaryLikeForItems 将保留后的 todo 列表压缩成事实层需要的计数。 -func todoSummaryLikeForItems(items []agentsession.TodoItem) runtimefacts.TodoSummaryLike { - var summary runtimefacts.TodoSummaryLike - for _, item := range items { - if !item.RequiredValue() { - continue - } - if item.Status.IsTerminal() { - if item.Status == agentsession.TodoStatusFailed { - summary.RequiredFailed++ - } else { - summary.RequiredCompleted++ - } - continue - } - summary.RequiredOpen++ - } - return summary -} diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index f3d868ae1..efc3cc71d 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -14,7 +14,6 @@ import ( "neo-code/internal/checkpoint" providertypes "neo-code/internal/provider/types" "neo-code/internal/repository" - runtimefacts "neo-code/internal/runtime/facts" runtimehooks "neo-code/internal/runtime/hooks" "neo-code/internal/tools" ) @@ -371,19 +370,7 @@ func (s *Service) emitCompletedToolCallResult( ) { s.emitRunScoped(ctx, EventToolResult, state, result) s.emitTodoToolEvent(ctx, state, call, result, execErr) - state.mu.Lock() - hasFactsUpdate := false - if state.factsCollector != nil { - state.factsCollector.ApplyToolResult(call.Name, result) - hasFactsUpdate = true - } - state.mu.Unlock() - if hasFactsUpdate { - s.emitFactsUpdated(state, "tool_result") - if strings.EqualFold(strings.TrimSpace(call.Name), tools.ToolNameSpawnSubAgent) { - s.emitSubAgentSnapshotUpdated(state, "tool_result") - } - } + s.emitSubAgentSnapshotEvents(ctx, state, call, result) if isSuccessfulRememberToolCall(call.Name, result, execErr) { state.mu.Lock() @@ -392,6 +379,26 @@ func (s *Service) emitCompletedToolCallResult( } } +// emitSubAgentSnapshotEvents 在 spawn_subagent 结果写回后刷新独立聚合快照。 +func (s *Service) emitSubAgentSnapshotEvents( + ctx context.Context, + state *runState, + call providertypes.ToolCall, + result tools.ToolResult, +) { + if state == nil || !strings.EqualFold(strings.TrimSpace(call.Name), tools.ToolNameSpawnSubAgent) { + return + } + state.mu.Lock() + changed := state.subAgentSnapshot.applySpawnResult(result) + state.mu.Unlock() + if !changed { + return + } + s.emitSubAgentSnapshotUpdated(state, "tool_result") + s.emitRuntimeSnapshotUpdated(ctx, state, "subagent_updated") +} + // resolveToolParallelism 计算本轮工具执行的并发上限,避免无界 goroutine 扩散。 func resolveToolParallelism(toolCallCount int) int { if toolCallCount <= 0 { @@ -466,15 +473,6 @@ func (s *Service) emitTodoToolEvent( action, _ := result.Metadata["action"].(string) payload := buildTodoEventPayload(state, strings.TrimSpace(action), "") if execErr == nil && !result.IsError { - state.mu.Lock() - if state.factsCollector != nil { - state.factsCollector.ApplyTodoSnapshot(runtimefacts.TodoSummaryLike{ - RequiredOpen: payload.Summary.RequiredOpen, - RequiredCompleted: payload.Summary.RequiredCompleted, - RequiredFailed: payload.Summary.RequiredFailed, - }) - } - state.mu.Unlock() s.emitRunScoped(ctx, EventTodoUpdated, state, payload) s.emitRunScoped(ctx, EventTodoSnapshotUpdated, state, payload) s.emitRuntimeSnapshotUpdated(ctx, state, "todo_updated") @@ -493,22 +491,6 @@ func (s *Service) emitTodoToolEvent( reason = "todo_write_failed" } payload.Reason = reason - state.mu.Lock() - hasFactsUpdate := false - if state.factsCollector != nil { - state.factsCollector.ApplyTodoSnapshot(runtimefacts.TodoSummaryLike{ - RequiredOpen: payload.Summary.RequiredOpen, - RequiredCompleted: payload.Summary.RequiredCompleted, - RequiredFailed: payload.Summary.RequiredFailed, - }) - conflictIDs := extractTodoIDsFromPayload(payload.Items) - state.factsCollector.ApplyTodoConflict(conflictIDs) - hasFactsUpdate = true - } - state.mu.Unlock() - if hasFactsUpdate { - s.emitFactsUpdated(state, "todo_conflict") - } s.emitRunScoped(ctx, EventTodoConflict, state, payload) s.emitRunScoped(ctx, EventTodoSnapshotUpdated, state, payload) s.emitRuntimeSnapshotUpdated(ctx, state, "todo_conflict") diff --git a/internal/runtime/user_hooks.go b/internal/runtime/user_hooks.go index 5446a0758..c9557e2e8 100644 --- a/internal/runtime/user_hooks.go +++ b/internal/runtime/user_hooks.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" "net/http" "net/url" "os" + "os/exec" "path/filepath" "runtime" "slices" @@ -22,6 +24,7 @@ import ( const ( configuredHookKindBuiltin = "builtin" + configuredHookKindCommand = "command" configuredHookKindHTTP = "http" configuredHookModeSync = "sync" configuredHookModeObserve = "observe" @@ -208,6 +211,10 @@ func buildConfiguredHookSpec( handler, err = buildUserBuiltinHookHandler(strings.TrimSpace(item.Handler), item.Params, defaultWorkdir) specKind = runtimehooks.HookKindFunction specMode = runtimehooks.HookModeSync + case configuredHookKindCommand: + handler, err = buildUserCommandHookHandler(item.Params, defaultWorkdir) + specKind = runtimehooks.HookKindCommand + specMode = runtimehooks.HookModeSync case configuredHookKindHTTP: handler, err = buildUserHTTPObserveHookHandler(item) specKind = runtimehooks.HookKindHTTP @@ -247,6 +254,13 @@ func validateConfiguredHookItemForP6Lite(item config.RuntimeHookItemConfig, scop if mode != configuredHookModeSync { return fmt.Errorf("mode %q is not supported", item.Mode) } + case configuredHookKindCommand: + if mode != configuredHookModeSync { + return fmt.Errorf("mode %q is not supported for kind command (only sync)", item.Mode) + } + if strings.TrimSpace(readHookParamString(item.Params, "command")) == "" { + return fmt.Errorf("kind command requires params.command") + } case configuredHookKindHTTP: if mode != configuredHookModeObserve { return fmt.Errorf("mode %q is not supported for kind http (only observe)", item.Mode) @@ -258,7 +272,7 @@ func validateConfiguredHookItemForP6Lite(item config.RuntimeHookItemConfig, scop default: if isExternalHookKind(kind) { return fmt.Errorf( - "external hook kind %q is not supported in P6-lite; only builtin/http-observe hooks are enabled", + "external hook kind %q is not supported in current stage; only builtin/command/http-observe hooks are enabled", item.Kind, ) } @@ -364,6 +378,49 @@ func buildUserBuiltinHookHandler( } } +// buildUserCommandHookHandler 将命令型 hook 转为同步阻断处理器,并通过 stdin 传入上下文 JSON。 +func buildUserCommandHookHandler(params map[string]any, defaultWorkdir string) (runtimehooks.HookHandler, error) { + command := strings.TrimSpace(readHookParamString(params, "command")) + if command == "" { + return nil, fmt.Errorf("kind command requires params.command") + } + return func(ctx context.Context, input runtimehooks.HookContext) runtimehooks.HookResult { + workdir := resolveHookWorkdir(input, defaultWorkdir) + cmd := buildCommandHookProcess(ctx, command) + if strings.TrimSpace(workdir) != "" { + cmd.Dir = workdir + } + payload, err := json.Marshal(input) + if err != nil { + detail := fmt.Sprintf("command hook marshal input failed: %v", err) + return runtimehooks.HookResult{Status: runtimehooks.HookResultFailed, Message: detail, Error: detail} + } + cmd.Stdin = bytes.NewReader(payload) + output, err := cmd.CombinedOutput() + message := strings.TrimSpace(string(output)) + if err == nil { + return runtimehooks.HookResult{Status: runtimehooks.HookResultPass, Message: message} + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && (exitErr.ExitCode() == 1 || exitErr.ExitCode() == 2) { + return runtimehooks.HookResult{Status: runtimehooks.HookResultBlock, Message: message} + } + detail := strings.TrimSpace(message) + if detail == "" { + detail = err.Error() + } + return runtimehooks.HookResult{Status: runtimehooks.HookResultFailed, Message: detail, Error: err.Error()} + }, nil +} + +// buildCommandHookProcess 以当前平台的 shell 执行用户命令,保留脚本组合能力。 +func buildCommandHookProcess(ctx context.Context, command string) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.CommandContext(ctx, "powershell", "-Command", command) + } + return exec.CommandContext(ctx, "sh", "-c", command) +} + // buildUserHTTPObserveHookHandler 将 kind=http 的 observe 配置转换为观测回调处理器。 func buildUserHTTPObserveHookHandler(item config.RuntimeHookItemConfig) (runtimehooks.HookHandler, error) { endpoint := strings.TrimSpace(readHookParamString(item.Params, "url")) diff --git a/internal/runtime/user_hooks_test.go b/internal/runtime/user_hooks_test.go index 44e58b78f..f328687ec 100644 --- a/internal/runtime/user_hooks_test.go +++ b/internal/runtime/user_hooks_test.go @@ -1187,7 +1187,7 @@ func TestConfigureRuntimeHooksRejectsExternalKindAndDoesNotRegister(t *testing.T Enabled: runtimeBoolPtr(true), Point: "before_tool_call", Scope: "user", - Kind: "command", + Kind: "prompt", Mode: "sync", Handler: "warn_on_tool_call", Params: map[string]any{"tool_name": "bash"}, @@ -1200,8 +1200,8 @@ func TestConfigureRuntimeHooksRejectsExternalKindAndDoesNotRegister(t *testing.T if err == nil { t.Fatal("expected external kind to be rejected") } - if !strings.Contains(err.Error(), "not supported in P6-lite") { - t.Fatalf("error=%q, want contains not supported in P6-lite", err.Error()) + if !strings.Contains(err.Error(), "not supported in current stage") { + t.Fatalf("error=%q, want contains not supported in current stage", err.Error()) } if service.hookExecutor != nil { t.Fatalf("unexpected hook executor after external kind rejection: %T", service.hookExecutor) diff --git a/internal/session/plan.go b/internal/session/plan.go index 1c1f6ab88..40fe982cc 100644 --- a/internal/session/plan.go +++ b/internal/session/plan.go @@ -1,10 +1,7 @@ package session import ( - "encoding/json" "fmt" - "sort" - "strconv" "strings" "time" ) @@ -29,38 +26,9 @@ const ( const ( maxSummaryKeySteps = 5 maxSummaryConstraints = 5 - maxSummaryVerify = 5 maxSummaryTodoIDs = 20 ) -const ( - // AcceptCheckOutputOnly 表示仅需要最终回复文本作为交付物。 - AcceptCheckOutputOnly = "output_only" - // AcceptCheckWorkspaceChange 表示需要运行期观测到 agent 产生工作区变更。 - AcceptCheckWorkspaceChange = "workspace_change" - // AcceptCheckCommandSuccess 表示需要运行期命令成功事实。 - AcceptCheckCommandSuccess = "command_success" - // AcceptCheckFileExists 表示需要运行期文件存在或写入事实。 - AcceptCheckFileExists = "file_exists" - // AcceptCheckContentContains 表示需要运行期内容匹配事实。 - AcceptCheckContentContains = "content_contains" - // AcceptCheckToolFact 表示需要运行期工具验证事实。 - AcceptCheckToolFact = "tool_fact" -) - -// AcceptCheck 声明 plan 阶段模型提出的机器可检查验收项。 -type AcceptCheck struct { - ID string `json:"id,omitempty"` - Kind string `json:"kind"` - Target string `json:"target,omitempty"` - Match string `json:"match,omitempty"` - Required *bool `json:"required,omitempty"` - Params map[string]string `json:"params,omitempty"` -} - -// AcceptChecks 保存 plan 级验收项,并兼容读取旧的 []string 格式。 -type AcceptChecks []AcceptCheck - // PlanArtifact stores the current plan persisted in the session. type PlanArtifact struct { ID string `json:"id"` @@ -74,21 +42,19 @@ type PlanArtifact struct { // PlanSpec is the source of truth for the current plan. type PlanSpec struct { - Goal string `json:"goal"` - Steps []string `json:"steps,omitempty"` - Constraints []string `json:"constraints,omitempty"` - Verify AcceptChecks `json:"verify,omitempty"` - Todos []TodoItem `json:"todos,omitempty"` - OpenQuestions []string `json:"open_questions,omitempty"` + Goal string `json:"goal"` + Steps []string `json:"steps,omitempty"` + Constraints []string `json:"constraints,omitempty"` + Todos []TodoItem `json:"todos,omitempty"` + OpenQuestions []string `json:"open_questions,omitempty"` } // SummaryView is the compact projection derived from PlanSpec. type SummaryView struct { - Goal string `json:"goal"` - KeySteps []string `json:"key_steps,omitempty"` - Constraints []string `json:"constraints,omitempty"` - Verify AcceptChecks `json:"verify,omitempty"` - ActiveTodoIDs []string `json:"active_todo_ids,omitempty"` + Goal string `json:"goal"` + KeySteps []string `json:"key_steps,omitempty"` + Constraints []string `json:"constraints,omitempty"` + ActiveTodoIDs []string `json:"active_todo_ids,omitempty"` } // Clone returns a deep copy of the plan artifact. @@ -107,7 +73,6 @@ func (p PlanSpec) Clone() PlanSpec { p.Goal = strings.TrimSpace(p.Goal) p.Steps = append([]string(nil), p.Steps...) p.Constraints = append([]string(nil), p.Constraints...) - p.Verify = p.Verify.Clone() p.OpenQuestions = append([]string(nil), p.OpenQuestions...) p.Todos = cloneTodoItems(p.Todos) return p @@ -118,7 +83,6 @@ func (s SummaryView) Clone() SummaryView { s.Goal = strings.TrimSpace(s.Goal) s.KeySteps = append([]string(nil), s.KeySteps...) s.Constraints = append([]string(nil), s.Constraints...) - s.Verify = s.Verify.Clone() s.ActiveTodoIDs = append([]string(nil), s.ActiveTodoIDs...) return s } @@ -190,7 +154,6 @@ func NormalizePlanSpec(spec PlanSpec) (PlanSpec, error) { spec.Goal = strings.TrimSpace(spec.Goal) spec.Steps = normalizeTodoTextList(spec.Steps) spec.Constraints = normalizeTodoTextList(spec.Constraints) - spec.Verify = spec.Verify.Normalize() spec.OpenQuestions = normalizeTodoTextList(spec.OpenQuestions) todos, err := normalizeAndValidateTodos(spec.Todos) @@ -211,7 +174,6 @@ func NormalizeSummaryView(summary SummaryView, spec PlanSpec) SummaryView { normalized.Goal = strings.TrimSpace(normalized.Goal) normalized.KeySteps = normalizeTodoTextList(normalized.KeySteps) normalized.Constraints = normalizeTodoTextList(normalized.Constraints) - normalized.Verify = normalized.Verify.Normalize() normalized.ActiveTodoIDs = normalizeTodoTextList(normalized.ActiveTodoIDs) if !summaryViewStructurallyValid(normalized, spec) { return BuildSummaryView(spec) @@ -229,7 +191,6 @@ func BuildSummaryView(spec PlanSpec) SummaryView { Goal: spec.Goal, KeySteps: clampStringList(spec.Steps, maxSummaryKeySteps), Constraints: clampStringList(spec.Constraints, maxSummaryConstraints), - Verify: clampAcceptChecks(spec.Verify, maxSummaryVerify), ActiveTodoIDs: collectActiveTodoIDs(spec.Todos, maxSummaryTodoIDs), } } @@ -241,7 +202,7 @@ func RenderPlanContent(spec PlanSpec) string { return "" } - sections := make([]string, 0, 6) + sections := make([]string, 0, 5) sections = append(sections, "目标\n"+spec.Goal) if len(spec.Steps) > 0 { sections = append(sections, "实施步骤\n"+renderBulletList(spec.Steps)) @@ -249,9 +210,6 @@ func RenderPlanContent(spec PlanSpec) string { if len(spec.Constraints) > 0 { sections = append(sections, "约束\n"+renderBulletList(spec.Constraints)) } - if len(spec.Verify) > 0 { - sections = append(sections, "验证\n"+renderBulletList(spec.Verify.RenderLines())) - } activeTodos := collectActiveTodoLines(spec.Todos) if len(activeTodos) > 0 { sections = append(sections, "当前待办\n"+renderBulletList(activeTodos)) @@ -266,7 +224,7 @@ func summaryViewStructurallyValid(summary SummaryView, spec PlanSpec) bool { if strings.TrimSpace(summary.Goal) == "" { return false } - if len(summary.KeySteps) == 0 || len(summary.Verify) == 0 { + if len(summary.KeySteps) == 0 && len(summary.ActiveTodoIDs) == 0 { return false } if len(summary.ActiveTodoIDs) == 0 { @@ -292,185 +250,6 @@ func clampStringList(items []string, maxItems int) []string { return append([]string(nil), normalized[:maxItems]...) } -// UnmarshalJSON 兼容读取新 AcceptCheck 对象数组与旧字符串数组。 -func (checks *AcceptChecks) UnmarshalJSON(data []byte) error { - var structured []AcceptCheck - if err := json.Unmarshal(data, &structured); err == nil { - *checks = AcceptChecks(structured).Normalize() - return nil - } - var legacy []string - if err := json.Unmarshal(data, &legacy); err != nil { - return err - } - migrated := make(AcceptChecks, 0, len(legacy)) - for _, item := range normalizeTodoTextList(legacy) { - migrated = append(migrated, migrateLegacyAcceptCheck(item)) - } - *checks = migrated.Normalize() - return nil -} - -// RequiredValue 返回验收项是否为必需项;nil 表示 JSON 省略字段,默认视为必需。 -func (check AcceptCheck) RequiredValue() bool { - if check.Required == nil { - return true - } - return *check.Required -} - -// Clone 返回验收项深拷贝,避免调用方共享 Params map。 -func (checks AcceptChecks) Clone() AcceptChecks { - if len(checks) == 0 { - return nil - } - out := make(AcceptChecks, 0, len(checks)) - for _, check := range checks { - cloned := check - cloned.ID = strings.TrimSpace(cloned.ID) - cloned.Kind = strings.TrimSpace(cloned.Kind) - cloned.Target = strings.TrimSpace(cloned.Target) - cloned.Match = strings.TrimSpace(cloned.Match) - if check.Required != nil { - required := *check.Required - cloned.Required = &required - } - if len(check.Params) > 0 { - cloned.Params = make(map[string]string, len(check.Params)) - for key, value := range check.Params { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" && value == "" { - continue - } - cloned.Params[key] = value - } - } - out = append(out, cloned) - } - return out -} - -// Normalize 规范化验收项文本字段并迁移旧 kind 名称。 -func (checks AcceptChecks) Normalize() AcceptChecks { - if len(checks) == 0 { - return nil - } - out := make(AcceptChecks, 0, len(checks)) - seen := make(map[string]struct{}, len(checks)) - for _, check := range checks.Clone() { - check.ID = strings.TrimSpace(check.ID) - check.Kind = normalizeAcceptCheckKind(check.Kind) - check.Target = strings.TrimSpace(check.Target) - check.Match = strings.TrimSpace(check.Match) - if check.Kind == "" && check.Target == "" && check.Match == "" { - continue - } - key := check.Kind + "\x00" + check.Target + "\x00" + check.Match + - "\x00" + paramsKey(check.Params) + - "\x00" + strconv.FormatBool(check.RequiredValue()) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - out = append(out, check) - } - if len(out) == 0 { - return nil - } - return out -} - -// paramsKey 将验收参数稳定序列化,用于区分同目标下的不同机器检查。 -func paramsKey(params map[string]string) string { - if len(params) == 0 { - return "" - } - keys := make([]string, 0, len(params)) - for key := range params { - keys = append(keys, key) - } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, key := range keys { - parts = append(parts, strconv.Quote(key)+"="+strconv.Quote(params[key])) - } - return strings.Join(parts, ";") -} - -// RenderLines 返回面向计划正文的稳定验收项文本。 -func (checks AcceptChecks) RenderLines() []string { - normalized := checks.Normalize() - if len(normalized) == 0 { - return nil - } - lines := make([]string, 0, len(normalized)) - for _, check := range normalized { - label := check.Kind - if check.Target != "" { - label += ": " + check.Target - } - lines = append(lines, label) - } - return lines -} - -func clampAcceptChecks(items AcceptChecks, maxItems int) AcceptChecks { - normalized := items.Normalize() - if len(normalized) <= maxItems || maxItems <= 0 { - return normalized - } - return normalized[:maxItems].Clone() -} - -func migrateLegacyAcceptCheck(value string) AcceptCheck { - kind := AcceptCheckOutputOnly - switch { - case looksLikeCommand(value): - kind = AcceptCheckCommandSuccess - case looksLikePath(value): - kind = AcceptCheckFileExists - } - return AcceptCheck{Kind: kind, Target: strings.TrimSpace(value)} -} - -func normalizeAcceptCheckKind(kind string) string { - normalized := strings.ToLower(strings.TrimSpace(kind)) - switch normalized { - case "command": - return AcceptCheckCommandSuccess - default: - return normalized - } -} - -func looksLikeCommand(value string) bool { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" { - return false - } - prefixes := []string{ - "go ", "go\t", "npm ", "pnpm ", "yarn ", "make", "cargo ", "python ", "pytest", "ruff ", - "eslint", "tsc", "golangci-lint", "git ", "powershell ", "pwsh ", - } - for _, prefix := range prefixes { - if strings.HasPrefix(trimmed, prefix) { - return true - } - } - return strings.Contains(trimmed, " test ") || strings.Contains(trimmed, " build ") -} - -func looksLikePath(value string) bool { - trimmed := strings.TrimSpace(value) - if trimmed == "" || strings.Contains(trimmed, " ") { - return false - } - return strings.Contains(trimmed, "/") || - strings.Contains(trimmed, "\\") || - strings.Contains(strings.TrimPrefix(trimmed, "."), ".") -} - func collectActiveTodoIDs(items []TodoItem, limit int) []string { if len(items) == 0 || limit <= 0 { return nil diff --git a/internal/session/plan_test.go b/internal/session/plan_test.go index f52d5f95f..73af2146e 100644 --- a/internal/session/plan_test.go +++ b/internal/session/plan_test.go @@ -1,73 +1,12 @@ package session import ( - "encoding/json" "fmt" "strings" "testing" "time" ) -func acceptText(target string) AcceptChecks { - return AcceptChecks{{Kind: AcceptCheckOutputOnly, Target: target}} -} - -func TestAcceptChecksUnmarshalRequiredDefaultAndExplicitFalse(t *testing.T) { - t.Parallel() - - var checks AcceptChecks - if err := json.Unmarshal([]byte(`[{"kind":"output_only"},{"kind":"tool_fact","required":false}]`), &checks); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if len(checks) != 2 { - t.Fatalf("len = %d, want 2", len(checks)) - } - if !checks[0].RequiredValue() { - t.Fatalf("omitted required should default to true: %+v", checks[0]) - } - if checks[1].RequiredValue() { - t.Fatalf("explicit required=false should stay optional: %+v", checks[1]) - } -} - -func TestAcceptChecksNormalizePreservesDistinctParams(t *testing.T) { - t.Parallel() - - checks := AcceptChecks{ - {Kind: AcceptCheckContentContains, Target: "README.md", Params: map[string]string{"contains": "NeoCode"}}, - {Kind: AcceptCheckContentContains, Target: "README.md", Params: map[string]string{"contains": "Todo"}}, - {Kind: AcceptCheckContentContains, Target: "README.md", Params: map[string]string{"contains": "NeoCode"}}, - } - - normalized := checks.Normalize() - if len(normalized) != 2 { - t.Fatalf("Normalize() length = %d, want 2: %+v", len(normalized), normalized) - } - if normalized[0].Params["contains"] != "NeoCode" || normalized[1].Params["contains"] != "Todo" { - t.Fatalf("Normalize() = %+v, want distinct contains params kept", normalized) - } -} - -func TestAcceptChecksNormalizePreservesDistinctRequired(t *testing.T) { - t.Parallel() - - required := true - optional := false - checks := AcceptChecks{ - {Kind: AcceptCheckCommandSuccess, Target: "go test ./...", Required: &required}, - {Kind: AcceptCheckCommandSuccess, Target: "go test ./...", Required: &optional}, - } - - normalized := checks.Normalize() - if len(normalized) != 2 { - t.Fatalf("Normalize() length = %d, want 2: %+v", len(normalized), normalized) - } - if !normalized[0].RequiredValue() || normalized[1].RequiredValue() { - t.Fatalf("Normalize() required flags = [%v %v], want [true false]", - normalized[0].RequiredValue(), normalized[1].RequiredValue()) - } -} - func TestNormalizeSummaryViewFallsBackToBuiltSummaryWhenStructurallyInvalid(t *testing.T) { t.Parallel() @@ -75,7 +14,6 @@ func TestNormalizeSummaryViewFallsBackToBuiltSummaryWhenStructurallyInvalid(t *t Goal: "为 runtime 引入 plan/build 模式", Steps: []string{"扩展 session", "过滤工具", "调整 runtime"}, Constraints: []string{"plan 模式禁止写工具"}, - Verify: acceptText("build 结束后进入 verify"), Todos: []TodoItem{ {ID: "todo-1", Content: "扩展 session", Status: TodoStatusPending}, {ID: "todo-2", Content: "过滤工具", Status: TodoStatusCompleted}, @@ -88,7 +26,6 @@ func TestNormalizeSummaryViewFallsBackToBuiltSummaryWhenStructurallyInvalid(t *t got := NormalizeSummaryView(SummaryView{ Goal: " ", KeySteps: []string{"仅一步"}, - Verify: acceptText("验收"), ActiveTodoIDs: []string{"missing"}, }, spec) want := BuildSummaryView(spec) @@ -108,9 +45,8 @@ func TestBuildSummaryViewUsesActiveNonTerminalTodosOnly(t *testing.T) { t.Parallel() spec, err := NormalizePlanSpec(PlanSpec{ - Goal: "整理当前执行摘要", - Steps: []string{"步骤一", "步骤二"}, - Verify: acceptText("验证一"), + Goal: "整理当前执行摘要", + Steps: []string{"步骤一", "步骤二"}, Todos: []TodoItem{ {ID: "todo-1", Content: "待执行", Status: TodoStatusPending}, {ID: "todo-2", Content: "执行中", Status: TodoStatusInProgress}, @@ -141,9 +77,8 @@ func TestNormalizePlanArtifactDefaultsAndStatusNormalization(t *testing.T) { Revision: 0, Status: PlanStatus("unknown"), Spec: PlanSpec{ - Goal: "规范化计划对象", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "规范化计划对象", + Steps: []string{"步骤一"}, }, }) if err != nil { @@ -175,9 +110,8 @@ func TestNormalizePlanArtifactPreservesCreatedAtAndNormalizesUpdatedAt(t *testin CreatedAt: created, UpdatedAt: updated, Spec: PlanSpec{ - Goal: "保留时间字段", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "保留时间字段", + Steps: []string{"步骤一"}, }, }) if err != nil { @@ -195,9 +129,8 @@ func TestNormalizeSummaryViewAllowsEmptyTodoRefsWhenPlanHasNoTodos(t *testing.T) t.Parallel() spec, err := NormalizePlanSpec(PlanSpec{ - Goal: "无 todo 计划", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "无 todo 计划", + Steps: []string{"步骤一"}, }) if err != nil { t.Fatalf("NormalizePlanSpec() error = %v", err) @@ -206,7 +139,6 @@ func TestNormalizeSummaryViewAllowsEmptyTodoRefsWhenPlanHasNoTodos(t *testing.T) summary := NormalizeSummaryView(SummaryView{ Goal: "无 todo 计划", KeySteps: []string{"步骤一"}, - Verify: acceptText("验证一"), }, spec) if summary.Goal != "无 todo 计划" { t.Fatalf("Goal = %q", summary.Goal) @@ -223,7 +155,6 @@ func TestRenderPlanContentIncludesAllSections(t *testing.T) { Goal: "输出完整计划正文", Steps: []string{"步骤一", "步骤二"}, Constraints: []string{"约束一"}, - Verify: acceptText("验证一"), OpenQuestions: []string{"问题一"}, Todos: []TodoItem{ {ID: "todo-1", Content: "待执行", Status: TodoStatusPending}, @@ -236,7 +167,6 @@ func TestRenderPlanContentIncludesAllSections(t *testing.T) { "输出完整计划正文", "实施步骤", "约束", - "验证", "当前待办", "id=todo-1", "未决问题", @@ -276,7 +206,7 @@ func TestRenderPlanContentOmittedSections(t *testing.T) { } } // Should not contain these sections when empty - unwanted := []string{"实施步骤", "约束", "验证", "当前待办", "未决问题"} + unwanted := []string{"实施步骤", "约束", "当前待办", "未决问题"} for _, unwantedStr := range unwanted { if strings.Contains(rendered, unwantedStr) { t.Fatalf("RenderPlanContent() = %q, should not contain %q", rendered, unwantedStr) @@ -313,9 +243,8 @@ func TestNormalizePlanArtifactEmptyID(t *testing.T) { _, err := NormalizePlanArtifact(&PlanArtifact{ ID: "", Spec: PlanSpec{ - Goal: "测试", - Steps: []string{"步骤一"}, - Verify: acceptText("验证一"), + Goal: "测试", + Steps: []string{"步骤一"}, }, }) if err == nil { @@ -387,20 +316,19 @@ func TestClampStringListMaxItems(t *testing.T) { func TestSummaryViewStructurallyValidDetectsInvalid(t *testing.T) { t.Parallel() - spec := PlanSpec{Goal: "目标", Steps: []string{"步骤一"}, Verify: acceptText("验证一")} + spec := PlanSpec{Goal: "目标", Steps: []string{"步骤一"}} // Empty goal if summaryViewStructurallyValid(SummaryView{}, spec) { t.Fatal("expected false for empty summary") } // Missing key steps - if summaryViewStructurallyValid(SummaryView{Goal: "目标", Verify: acceptText("v")}, spec) { + if summaryViewStructurallyValid(SummaryView{Goal: "目标"}, spec) { t.Fatal("expected false for missing key steps") } // Unknown active todo IDs if summaryViewStructurallyValid(SummaryView{ Goal: "目标", KeySteps: []string{"步骤一"}, - Verify: acceptText("验证一"), ActiveTodoIDs: []string{"unknown"}, }, spec) { t.Fatal("expected false for unknown todo IDs") diff --git a/internal/session/store_test.go b/internal/session/store_test.go index b9ec1e4ba..2f1106a42 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -620,7 +620,6 @@ func TestSQLiteStorePersistsPlanStateRoundTrip(t *testing.T) { Goal: "落地 plan/build 模式", Steps: []string{"扩展 session", "扩展 runtime"}, Constraints: []string{"保持 tools 边界"}, - Verify: AcceptChecks{{Kind: AcceptCheckCommandSuccess, Target: "go test ./internal/..."}}, Todos: []TodoItem{ {ID: "todo-plan-1", Content: "补 plan 模型"}, }, @@ -629,7 +628,6 @@ func TestSQLiteStorePersistsPlanStateRoundTrip(t *testing.T) { Goal: "落地 plan/build 模式", KeySteps: []string{"扩展 session", "扩展 runtime"}, Constraints: []string{"保持 tools 边界"}, - Verify: AcceptChecks{{Kind: AcceptCheckCommandSuccess, Target: "go test ./internal/..."}}, ActiveTodoIDs: []string{"todo-plan-1"}, }, }, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index d21354fe6..f59b118be 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -3025,6 +3025,7 @@ var runtimeEventHandlerRegistry = map[tuiservices.EventType]func(*App, tuiservic tuiservices.EventUserQuestionAnswered: runtimeEventUserQuestionResolvedHandler, tuiservices.EventUserQuestionSkipped: runtimeEventUserQuestionResolvedHandler, tuiservices.EventUserQuestionTimeout: runtimeEventUserQuestionResolvedHandler, + tuiservices.EventCompactStart: runtimeEventCompactStartHandler, tuiservices.EventCompactApplied: runtimeEventCompactDoneHandler, tuiservices.EventCompactError: runtimeEventCompactErrorHandler, tuiservices.EventTokenUsage: runtimeEventTokenUsageHandler, @@ -3069,9 +3070,8 @@ var runtimeEventHandlerRegistry = map[tuiservices.EventType]func(*App, tuiservic tuiservices.EventSubAgentToolCallResult: runtimeEventSubAgentToolCallHandler, tuiservices.EventSubAgentToolCallDenied: runtimeEventSubAgentToolCallHandler, tuiservices.EventRuntimeSnapshotUpdated: runtimeEventRuntimeSnapshotUpdatedHandler, - tuiservices.EventFactsUpdated: runtimeEventFactsUpdatedHandler, - tuiservices.EventDecisionMade: runtimeEventDecisionMadeHandler, tuiservices.EventSubAgentSnapshotUpdated: runtimeEventSubAgentSnapshotUpdatedHandler, + tuiservices.EventDecisionMade: runtimeEventDecisionMadeHandler, } func hookActivityLabel(source string, hookID string) string { @@ -3496,6 +3496,22 @@ func runtimeEventSubAgentToolCallHandler(a *App, event tuiservices.RuntimeEvent) return false } +// runtimeEventSubAgentSnapshotUpdatedHandler 处理 subagent_snapshot_updated 事件,输出聚合计数。 +func runtimeEventSubAgentSnapshotUpdatedHandler(a *App, event tuiservices.RuntimeEvent) bool { + payload, ok := event.Payload.(tuiservices.SubAgentSnapshotUpdatedPayload) + if !ok { + return false + } + detail := fmt.Sprintf( + "started=%d completed=%d failed=%d", + payload.SubAgent.StartedCount, + payload.SubAgent.CompletedCount, + payload.SubAgent.FailedCount, + ) + a.appendActivity("subagent", "SubAgent snapshot updated", detail, false) + return false +} + func runtimeEventPhaseChangedHandler(a *App, event tuiservices.RuntimeEvent) bool { payload, ok := event.Payload.(tuiservices.PhaseChangedPayload) if !ok { @@ -3693,6 +3709,7 @@ func runtimeEventStopReasonDecidedHandler(a *App, event tuiservices.RuntimeEvent } case strings.ToLower(string(tuiservices.StopReasonTodoNotConverged)), strings.ToLower(string(tuiservices.StopReasonTodoWaitingExternal)), + strings.ToLower(string(tuiservices.StopReasonAcceptContinue)), strings.ToLower(string(tuiservices.StopReasonEmptyResponse)), strings.ToLower(string(tuiservices.StopReasonRepeatCycle)), strings.ToLower(string(tuiservices.StopReasonMaxTurnExceededWithUnconvergedTodos)), @@ -3709,7 +3726,7 @@ func runtimeEventStopReasonDecidedHandler(a *App, event tuiservices.RuntimeEvent a.state.StatusText = statusCanceled a.appendActivity("run", "Canceled current run", "", false) case strings.ToLower(string(tuiservices.StopReasonVerificationFailed)), - strings.ToLower(string(tuiservices.StopReasonAcceptCheckFailed)), + strings.ToLower(string(tuiservices.StopReasonAcceptContinueExhausted)), strings.ToLower(string(tuiservices.StopReasonRequiredTodoFailed)), strings.ToLower(string(tuiservices.StopReasonVerificationExecutionDenied)), strings.ToLower(string(tuiservices.StopReasonVerificationExecutionError)): @@ -3879,26 +3896,6 @@ func runtimeEventRuntimeSnapshotUpdatedHandler(a *App, event tuiservices.Runtime return false } -// runtimeEventFactsUpdatedHandler 处理 facts_updated 事件,输出简洁事实更新日志。 -func runtimeEventFactsUpdatedHandler(a *App, event tuiservices.RuntimeEvent) bool { - payload, ok := event.Payload.(tuiservices.FactsUpdatedPayload) - if !ok { - return false - } - reason := strings.TrimSpace(payload.Reason) - if reason == "" { - reason = "facts updated" - } - detail := reason - if runtimeFacts, ok := payload.Facts.RuntimeFacts["progress"].(map[string]any); ok { - if observed := coerceInt(runtimeFacts["observed_fact_count"]); observed > 0 { - detail = fmt.Sprintf("%s (observed_facts=%d)", reason, observed) - } - } - a.appendActivity("facts", "Runtime facts updated", detail, false) - return false -} - // runtimeEventDecisionMadeHandler 处理 decision_made 事件,输出最终裁决摘要。 func runtimeEventDecisionMadeHandler(a *App, event tuiservices.RuntimeEvent) bool { payload, ok := event.Payload.(tuiservices.DecisionMadePayload) @@ -3935,22 +3932,6 @@ func runtimeEventDecisionMadeHandler(a *App, event tuiservices.RuntimeEvent) boo return false } -// runtimeEventSubAgentSnapshotUpdatedHandler 处理 subagent_snapshot_updated 事件,输出聚合计数。 -func runtimeEventSubAgentSnapshotUpdatedHandler(a *App, event tuiservices.RuntimeEvent) bool { - payload, ok := event.Payload.(tuiservices.SubAgentSnapshotUpdatedPayload) - if !ok { - return false - } - detail := fmt.Sprintf( - "started=%d completed=%d failed=%d", - payload.SubAgent.StartedCount, - payload.SubAgent.CompletedCount, - payload.SubAgent.FailedCount, - ) - a.appendActivity("subagent", "SubAgent snapshot updated", detail, false) - return false -} - // runtimeEventSkillActivatedHandler 在 runtime 激活 skill 后同步活动日志。 func runtimeEventSkillActivatedHandler(a *App, event tuiservices.RuntimeEvent) bool { payload, ok := parseSessionSkillEventPayload(event.Payload) @@ -4696,6 +4677,7 @@ func runtimeEventCompactDoneHandler(a *App, event tuiservices.RuntimeEvent) bool return false } a.state.ExecutionError = "" + a.state.IsCompacting = false a.state.StatusText = fmt.Sprintf("Compact(%s) saved %.1f%% context", payload.TriggerMode, payload.SavedRatio*100) a.appendInlineMessage( roleSystem, @@ -4719,10 +4701,28 @@ func runtimeEventCompactErrorHandler(a *App, event tuiservices.RuntimeEvent) boo } message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) a.state.ExecutionError = message + a.state.IsCompacting = false a.state.StatusText = message a.appendInlineMessage(roleError, message) return true } + +func runtimeEventCompactStartHandler(a *App, event tuiservices.RuntimeEvent) bool { + mode, ok := event.Payload.(string) + if !ok { + return false + } + a.state.IsCompacting = true + a.state.StreamingReply = false + a.state.CurrentTool = "" + if mode != "" { + a.state.StatusText = fmt.Sprintf("Compacting (%s)...", mode) + } else { + a.state.StatusText = statusCompacting + } + a.state.ExecutionError = "" + return true +} func (a *App) appendAssistantChunk(chunk string) { if chunk == "" { return diff --git a/internal/tui/core/app/update_runtime_events_test.go b/internal/tui/core/app/update_runtime_events_test.go index 722311874..701f3e96a 100644 --- a/internal/tui/core/app/update_runtime_events_test.go +++ b/internal/tui/core/app/update_runtime_events_test.go @@ -250,38 +250,29 @@ func TestRuntimeEventHandlerRegistryContainsRenamedEvents(t *testing.T) { if _, ok := runtimeEventHandlerRegistry[agentruntime.EventSubAgentToolCallResult]; !ok { t.Fatalf("expected subagent_tool_call_result handler to be registered") } + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventSubAgentSnapshotUpdated]; !ok { + t.Fatalf("expected subagent_snapshot_updated handler to be registered") + } if _, ok := runtimeEventHandlerRegistry[agentruntime.EventRuntimeSnapshotUpdated]; !ok { t.Fatalf("expected runtime_snapshot_updated handler to be registered") } - if _, ok := runtimeEventHandlerRegistry[agentruntime.EventFactsUpdated]; !ok { - t.Fatalf("expected facts_updated handler to be registered") - } if _, ok := runtimeEventHandlerRegistry[agentruntime.EventDecisionMade]; !ok { t.Fatalf("expected decision_made handler to be registered") } - if _, ok := runtimeEventHandlerRegistry[agentruntime.EventSubAgentSnapshotUpdated]; !ok { - t.Fatalf("expected subagent_snapshot_updated handler to be registered") - } if _, ok := runtimeEventHandlerRegistry[agentruntime.EventTodoSnapshotUpdated]; !ok { t.Fatalf("expected todo_snapshot_updated handler to be registered") } } -func TestRuntimeSnapshotAndFactsHandlers(t *testing.T) { +func TestRuntimeSnapshotAndDecisionHandlers(t *testing.T) { app, _ := newTestApp(t) if runtimeEventRuntimeSnapshotUpdatedHandler(&app, agentruntime.RuntimeEvent{Payload: "bad"}) { t.Fatalf("expected invalid runtime snapshot payload to return false") } - if runtimeEventFactsUpdatedHandler(&app, agentruntime.RuntimeEvent{Payload: 1}) { - t.Fatalf("expected invalid facts payload to return false") - } if runtimeEventDecisionMadeHandler(&app, agentruntime.RuntimeEvent{Payload: true}) { t.Fatalf("expected invalid decision payload to return false") } - if runtimeEventSubAgentSnapshotUpdatedHandler(&app, agentruntime.RuntimeEvent{Payload: []string{"bad"}}) { - t.Fatalf("expected invalid subagent snapshot payload to return false") - } runtimeEventRuntimeSnapshotUpdatedHandler(&app, agentruntime.RuntimeEvent{ Payload: agentruntime.RuntimeSnapshotUpdatedPayload{ @@ -600,6 +591,9 @@ func TestRuntimeEventSubAgentHandlers(t *testing.T) { }) { t.Fatalf("expected invalid subagent tool call payload to return false") } + if runtimeEventSubAgentSnapshotUpdatedHandler(&app, agentruntime.RuntimeEvent{Payload: 1}) { + t.Fatalf("expected invalid subagent snapshot payload to return false") + } runtimeEventSubAgentToolCallHandler(&app, agentruntime.RuntimeEvent{ Type: agentruntime.EventSubAgentToolCallResult, Payload: agentruntime.SubAgentToolCallEventPayload{ @@ -614,6 +608,20 @@ func TestRuntimeEventSubAgentHandlers(t *testing.T) { if last.Title != "SubAgent tool call result" || !strings.Contains(last.Detail, "tool=bash") || last.IsError { t.Fatalf("unexpected subagent tool call result activity: %+v", last) } + + runtimeEventSubAgentSnapshotUpdatedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.SubAgentSnapshotUpdatedPayload{ + SubAgent: agentruntime.SubAgentSnapshot{ + StartedCount: 2, + CompletedCount: 1, + FailedCount: 1, + }, + }, + }) + last = app.activities[len(app.activities)-1] + if last.Title != "SubAgent snapshot updated" || !strings.Contains(last.Detail, "started=2 completed=1 failed=1") || last.IsError { + t.Fatalf("unexpected subagent snapshot activity: %+v", last) + } } func TestShouldHandleRuntimeEventFiltersBySessionAndRun(t *testing.T) { @@ -801,7 +809,7 @@ func TestRuntimeEventVerificationAndAcceptanceHandlers(t *testing.T) { Payload: agentruntime.AcceptanceDecidedPayload{ Status: "failed", Summary: "command_success: missing successful command evidence", - StopReason: agentruntime.StopReasonAcceptCheckFailed, + StopReason: agentruntime.StopReasonAcceptContinueExhausted, Results: []agentruntime.AcceptanceCheckResult{ { Passed: false, diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index bd4fc40fe..d2583b251 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -98,7 +98,9 @@ func (a App) renderFooter(width int) string { func (a App) renderHeader(width int) string { status := compactStatusText(a.state.StatusText, max(18, width/3)) - if a.state.IsAgentRunning { + if a.state.IsCompacting { + status = compactStatusText(a.state.StatusText, max(18, width/3)) + } else if a.state.IsAgentRunning { if a.runProgressKnown { phaseLabel := tuiutils.Fallback(strings.TrimSpace(a.runProgressLabel), tuiutils.Fallback(status, statusRunning)) status = a.spinner.View() + " " + phaseLabel diff --git a/internal/tui/services/gateway_stream_client.go b/internal/tui/services/gateway_stream_client.go index bf2b3452b..86d7eb1f9 100644 --- a/internal/tui/services/gateway_stream_client.go +++ b/internal/tui/services/gateway_stream_client.go @@ -255,19 +255,17 @@ func restoreRuntimePayload(eventType EventType, payload any) (any, error) { return decodeRuntimePayload[SubAgentToolCallEventPayload](payload) case EventRuntimeSnapshotUpdated: return decodeRuntimePayload[RuntimeSnapshotUpdatedPayload](payload) - case EventFactsUpdated: - return decodeRuntimePayload[FactsUpdatedPayload](payload) - case EventDecisionMade: - return decodeRuntimePayload[DecisionMadePayload](payload) case EventSubAgentSnapshotUpdated: return decodeRuntimePayload[SubAgentSnapshotUpdatedPayload](payload) + case EventDecisionMade: + return decodeRuntimePayload[DecisionMadePayload](payload) case EventType(RuntimeEventRunContext): return decodeRuntimePayload[RuntimeRunContextPayload](payload) case EventType(RuntimeEventToolStatus): return decodeRuntimePayload[RuntimeToolStatusPayload](payload) case EventType(RuntimeEventUsage): return decodeRuntimePayload[RuntimeUsagePayload](payload) - case EventAgentChunk, EventToolChunk, EventError, EventToolCallThinking: + case EventAgentChunk, EventToolChunk, EventError, EventToolCallThinking, EventCompactStart: return decodeStringPayload(payload), nil default: return payload, nil diff --git a/internal/tui/services/gateway_stream_client_additional_test.go b/internal/tui/services/gateway_stream_client_additional_test.go index c049d1aa5..9edd3c7c0 100644 --- a/internal/tui/services/gateway_stream_client_additional_test.go +++ b/internal/tui/services/gateway_stream_client_additional_test.go @@ -430,9 +430,8 @@ func TestRestoreRuntimePayloadAdditionalBranches(t *testing.T) { {eventType: EventTodoConflict, payload: map[string]any{"action": "conflict"}}, {eventType: EventTodoSnapshotUpdated, payload: map[string]any{"action": "snapshot"}}, {eventType: EventRuntimeSnapshotUpdated, payload: map[string]any{"reason": "tool_result", "snapshot": map[string]any{"run_id": "run-1"}}}, - {eventType: EventFactsUpdated, payload: map[string]any{"reason": "tool_result", "facts": map[string]any{"runtime_facts": map[string]any{}}}}, - {eventType: EventDecisionMade, payload: map[string]any{"status": "continue", "stop_reason": "todo_not_converged"}}, {eventType: EventSubAgentSnapshotUpdated, payload: map[string]any{"reason": "tool_result", "subagent": map[string]any{"started_count": 1}}}, + {eventType: EventDecisionMade, payload: map[string]any{"status": "continue", "stop_reason": "todo_not_converged"}}, {eventType: EventType(RuntimeEventRunContext), payload: map[string]any{"provider": "openai"}}, {eventType: EventType(RuntimeEventToolStatus), payload: map[string]any{"status": "running"}}, } diff --git a/internal/tui/services/runtime_contract.go b/internal/tui/services/runtime_contract.go index 79783a8cb..8ef62e678 100644 --- a/internal/tui/services/runtime_contract.go +++ b/internal/tui/services/runtime_contract.go @@ -308,8 +308,10 @@ const ( StopReasonAccepted StopReason = "accepted" // StopReasonEmptyResponse 表示模型连续返回空文本响应。 StopReasonEmptyResponse StopReason = "empty_response" - // StopReasonAcceptCheckFailed 表示最终 Accept Gate 的验收项失败。 - StopReasonAcceptCheckFailed StopReason = "accept_check_failed" + // StopReasonAcceptContinue 表示验收流程要求模型继续工作。 + StopReasonAcceptContinue StopReason = "accept_continue" + // StopReasonAcceptContinueExhausted 表示验收继续次数已耗尽。 + StopReasonAcceptContinueExhausted StopReason = "accept_continue_exhausted" // StopReasonTodoNotConverged 表示 required todo 未收敛。 StopReasonTodoNotConverged StopReason = "todo_not_converged" // StopReasonTodoWaitingExternal 表示 todo 等待外部输入。 @@ -439,7 +441,6 @@ type RuntimeSnapshot struct { TaskKind string `json:"task_kind,omitempty"` UpdatedAt time.Time `json:"updated_at"` Todos TodoSnapshot `json:"todos"` - Facts FactsSnapshot `json:"facts"` Decision DecisionSnapshot `json:"decision,omitempty"` SubAgents SubAgentSnapshot `json:"subagents,omitempty"` } @@ -450,11 +451,6 @@ type TodoSnapshot struct { Summary TodoSummary `json:"summary,omitempty"` } -// FactsSnapshot 描述统一事实快照。 -type FactsSnapshot struct { - RuntimeFacts map[string]any `json:"runtime_facts"` -} - // DecisionSnapshot 描述最终裁决快照。 type DecisionSnapshot struct { Status string `json:"status,omitempty"` @@ -465,7 +461,7 @@ type DecisionSnapshot struct { InternalSummary string `json:"internal_summary,omitempty"` } -// SubAgentSnapshot 描述子代理事实聚合快照。 +// SubAgentSnapshot 描述当前 run 内的子代理聚合计数。 type SubAgentSnapshot struct { StartedCount int `json:"started_count"` CompletedCount int `json:"completed_count"` @@ -478,12 +474,6 @@ type RuntimeSnapshotUpdatedPayload struct { Snapshot RuntimeSnapshot `json:"snapshot"` } -// FactsUpdatedPayload 描述 facts_updated 事件。 -type FactsUpdatedPayload struct { - Reason string `json:"reason,omitempty"` - Facts FactsSnapshot `json:"facts"` -} - // DecisionMadePayload 描述 decision_made 事件。 type DecisionMadePayload struct { Status string `json:"status"` @@ -730,8 +720,7 @@ const ( EventSubAgentToolCallResult EventType = "subagent_tool_call_result" EventSubAgentToolCallDenied EventType = "subagent_tool_call_denied" EventRuntimeSnapshotUpdated EventType = "runtime_snapshot_updated" - EventFactsUpdated EventType = "facts_updated" - EventDecisionMade EventType = "decision_made" EventSubAgentSnapshotUpdated EventType = "subagent_snapshot_updated" + EventDecisionMade EventType = "decision_made" EventTodoSnapshotUpdated EventType = "todo_snapshot_updated" )