Files
2026-07-13 13:00:08 +08:00

1162 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package control
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
func TestGoalCommandAutoContinuesUntilComplete(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Started the goal work.\n\n[goal:continue]"),
textTurn("Finished the goal work.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal ship the redesign")
waitForTurnDone(t, events)
if prov.call != 2 {
t.Fatalf("provider calls = %d, want 2 (initial + automatic continuation)", prov.call)
}
if got := c.Goal(); got != "" {
t.Fatalf("completed goal should be cleared, got %q", got)
}
if got := c.GoalStatus(); got != GoalStatusComplete {
t.Fatalf("GoalStatus() = %q, want complete", got)
}
first := firstUserMessage(ag.Session().Messages)
if !strings.Contains(first, "<active-goal>\nship the redesign") {
t.Fatalf("first goal turn should include active goal block, got %q", first)
}
if strings.HasPrefix(first, PlanModeMarker) {
t.Fatalf("goal mode should not enter plan mode, got %q", first)
}
}
func TestActiveGoalBlockCarriesTaskContractAndPausePolicy(t *testing.T) {
block := activeGoalBlock("fix the parser", GoalResearchOff)
for _, want := range []string{
"Treat the user's goal as a task contract",
"Context, Request, Output format, Constraints",
"Pause policy",
"irreversible or externally visible operation",
"the requested scope has changed",
"information only the user can provide",
"output format and constraints are satisfied",
} {
if !strings.Contains(block, want) {
t.Fatalf("active goal block missing %q:\n%s", want, block)
}
}
if strings.Contains(block, "AutoResearch protocol") {
t.Fatalf("simple goal should not include AutoResearch protocol:\n%s", block)
}
}
func TestGoalModeSkipsAutoPlanApproval(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Implemented the requested work.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
approvalRequests := make(chan event.Approval, 1)
events := make(chan event.Event, 4)
c := New(Options{
AutoPlan: "on",
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
switch e.Kind {
case event.ApprovalRequest:
approvalRequests <- e.Approval
case event.TurnDone:
events <- e
}
}),
})
c.Submit("/goal 实现一个复杂功能,修改代码,补测试,并更新文档")
waitForTurnDone(t, events)
select {
case approval := <-approvalRequests:
t.Fatalf("goal mode should not request plan approval under auto-plan; got %+v", approval)
default:
}
if c.PlanMode() {
t.Fatal("goal mode should leave plan mode off")
}
if got := firstUserMessage(ag.Session().Messages); strings.HasPrefix(got, PlanModeMarker) {
t.Fatalf("goal mode should not prepend plan marker, got %q", got)
}
}
func TestPlainInputWithStrongResearchSignalAutoStartsGoal(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("AutoResearch started and completed.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("持续排查这个线上卡顿直到根因明确,并验证修复")
waitForTurnDone(t, events)
if prov.call != 1 {
t.Fatalf("provider calls = %d, want 1", prov.call)
}
first := firstUserMessage(ag.Session().Messages)
for _, want := range []string{
"<active-goal>\n持续排查这个线上卡顿直到根因明确,并验证修复",
"AutoResearch protocol",
".reasonix/autoresearch/<task-id>/",
} {
if !strings.Contains(first, want) {
t.Fatalf("auto-started goal turn missing %q:\n%s", want, first)
}
}
if strings.HasPrefix(first, PlanModeMarker) {
t.Fatalf("auto-started research goal should not enter plan mode, got %q", first)
}
if got := c.GoalStatus(); got != GoalStatusComplete {
t.Fatalf("GoalStatus() = %q, want complete", got)
}
}
func TestPlainInputAutoStartedGoalPreservesRefs(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("important referenced evidence"), 0o644); err != nil {
t.Fatal(err)
}
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("AutoResearch started and completed.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
c := New(Options{
WorkspaceRoot: root,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("持续排查直到根因明确,并验证 @notes.txt")
waitForTurnDone(t, events)
first := firstUserMessage(ag.Session().Messages)
for _, want := range []string{
"<active-goal>\n持续排查直到根因明确,并验证 @notes.txt",
"Referenced context:",
"important referenced evidence",
"AutoResearch protocol",
} {
if !strings.Contains(first, want) {
t.Fatalf("auto-started goal with refs missing %q:\n%s", want, first)
}
}
}
func TestResearchGoalCreatesHostManagedAutoResearchTask(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
ag := agent.New(&scriptedTurns{}, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag})
c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn)
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatalf("read goal state: %v", err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatalf("unmarshal goal state: %v", err)
}
if state.AutoResearchTaskID == "" {
t.Fatalf("AutoResearchTaskID was empty in persisted goal state: %+v", state)
}
for _, rel := range []string{
"state/task_spec.json",
"state/progress.json",
"state/findings.jsonl",
"logs/heartbeat.jsonl",
} {
path := filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, rel)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected autoresearch file %s: %v", rel, err)
}
}
var spec struct {
SuccessCriteria []struct {
ID string `json:"id"`
Required bool `json:"required"`
} `json:"success_criteria"`
}
readJSONFileForTest(t, filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, "state", "task_spec.json"), &spec)
if len(spec.SuccessCriteria) != 2 || spec.SuccessCriteria[0].ID != "objective_evidence" || spec.SuccessCriteria[1].ID != "verification" {
t.Fatalf("default success criteria = %+v, want objective_evidence and verification", spec.SuccessCriteria)
}
for _, criterion := range spec.SuccessCriteria {
if !criterion.Required {
t.Fatalf("default criterion %+v was not required", criterion)
}
}
composed := c.Compose("continue")
if !strings.Contains(composed, "<autoresearch-runtime>") || !strings.Contains(composed, "task_id: "+state.AutoResearchTaskID) || !strings.Contains(composed, "objective_evidence") {
t.Fatalf("Compose missing runtime summary for task %q:\n%s", state.AutoResearchTaskID, composed)
}
}
func TestResearchGoalCreatedEmitsLifecycleNotice(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
events := make(chan event.Event, 4)
c := New(Options{
WorkspaceRoot: root,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
events <- e
}
}),
})
c.SetGoalWithResearchMode("investigate lifecycle notice", GoalResearchOn)
select {
case e := <-events:
if !strings.Contains(e.Text, "autoresearch task created") {
t.Fatalf("notice = %q, want autoresearch task created", e.Text)
}
default:
t.Fatal("expected autoresearch lifecycle notice")
}
}
func TestResearchGoalRepeatedSetReusesAutoResearchTask(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
events := make(chan event.Event, 8)
c := New(Options{
WorkspaceRoot: root,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
events <- e
}
}),
})
goal := "请持续研究当前项目的 AutoResearch 状态栏展示链路,验证任务创建、状态刷新、右侧 Context 面板展示、状态栏 chip 展示是否一致。不要只看表面现象,需要找到根因、记录 evidence,并在完成前确认所有验证步骤通过。不要修改文件"
c.SetGoalWithResearchMode(goal, GoalResearchOn)
_, _, _, firstTaskID := c.goals.snapshot()
if firstTaskID == "" {
t.Fatal("first AutoResearch task id was empty")
}
c.SetGoalWithResearchMode(goal, GoalResearchOn)
c.SetGoal(goal)
_, _, _, repeatedTaskID := c.goals.snapshot()
if repeatedTaskID != firstTaskID {
t.Fatalf("repeated SetGoal created a new task: got %q, want %q", repeatedTaskID, firstTaskID)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
if err != nil {
t.Fatalf("read autoresearch dir: %v", err)
}
if len(entries) != 1 {
t.Fatalf("autoresearch task count = %d, want 1", len(entries))
}
createdNotices := 0
for {
select {
case e := <-events:
if strings.Contains(e.Text, "autoresearch task created") {
createdNotices++
}
default:
if createdNotices != 1 {
t.Fatalf("created notices = %d, want 1", createdNotices)
}
return
}
}
}
func TestResearchGoalMissingExplicitTaskBlocksInsteadOfCreatingNewTask(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
c.SetGoalWithResearchMode("resume .reasonix/autoresearch/missing-task/", GoalResearchOn)
if got := c.GoalStatus(); got != GoalStatusBlocked {
t.Fatalf("GoalStatus() = %q, want blocked for missing explicit AutoResearch task", got)
}
if got := c.goals.currentAutoResearchTaskID(); got != "" {
t.Fatalf("current AutoResearch task id = %q, want none for missing explicit task", got)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
if err != nil && !os.IsNotExist(err) {
t.Fatalf("ReadDir autoresearch root: %v", err)
}
if len(entries) != 0 {
t.Fatalf("created tasks for missing explicit resume: %+v", entries)
}
if !containsNotice(notices, "autoresearch resume failed") || !containsNotice(notices, "missing-task") {
t.Fatalf("notices = %+v, want explicit resume failure", notices)
}
}
func TestResearchGoalTurnAppendsAutoResearchHeartbeats(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Finished.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 4)
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal --research fix the typo and add a test")
waitForTurnDone(t, events)
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatalf("read goal state: %v", err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatalf("unmarshal goal state: %v", err)
}
heartbeats, err := c.autoResearch.Heartbeats(state.AutoResearchTaskID, 10)
if err != nil {
t.Fatalf("Heartbeats: %v", err)
}
if len(heartbeats) < 2 {
t.Fatalf("heartbeats = %+v, want at least starting and done", heartbeats)
}
if heartbeats[0].Status != "starting_turn" || heartbeats[len(heartbeats)-1].Status != "turn_done" {
t.Fatalf("heartbeats = %+v, want starting_turn then turn_done", heartbeats)
}
}
func TestResearchGoalTurnUpdatesAutoResearchStaleProgress(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Still investigating.\n\n[goal:continue]"),
textTurn("No new evidence without external input.\n\n[goal:blocked:needs a repro trace]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal --research investigate stale progress")
waitForTurnDone(t, events)
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatalf("read goal state: %v", err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatalf("unmarshal goal state: %v", err)
}
summary, err := c.autoResearch.Summary(state.AutoResearchTaskID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Iteration < 2 || summary.StaleCount != summary.Iteration || !summary.PivotRequired || summary.PivotCount != 1 {
t.Fatalf("summary = %+v, want stale progress for every no-evidence turn and pivot required", summary)
}
}
func TestResearchGoalCompletionIsInterceptedWhenReadinessFails(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("I am done.\n\n[goal:complete]"),
textTurn("Still need evidence.\n\n[goal:blocked:missing evidence]"),
textTurn("Still need evidence.\n\n[goal:blocked:missing evidence]"),
textTurn("Still need evidence.\n\n[goal:blocked:missing evidence]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.SetGoalWithResearchMode("identify the root cause", GoalResearchOn)
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
}
if got := c.GoalStatus(); got == GoalStatusComplete {
t.Fatalf("GoalStatus() = complete, want readiness intercept to keep goal running")
}
if prov.call != 4 {
t.Fatalf("provider calls = %d, want initial + readiness intercept + blocked audit", prov.call)
}
if !sessionContainsUserText(ag.Session().Messages, "AutoResearch readiness check failed", "objective_evidence", "verification") {
t.Fatalf("transcript missing readiness intercept; last user:\n%s", lastUserMessage(ag.Session().Messages))
}
if !containsNotice(notices, "Goal is not ready to complete yet; continuing the remaining work.") {
t.Fatalf("notices = %+v, want readiness continuation notice", notices)
}
}
func TestControllerRecordsAutoResearchEvidence(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
c := New(Options{WorkspaceRoot: root})
c.SetGoalWithResearchMode("verify the fix", GoalResearchOn)
taskID := c.goals.currentAutoResearchTaskID()
if taskID == "" {
t.Fatal("expected autoresearch task id")
}
err := c.RecordAutoResearchEvidence("objective_evidence", AutoResearchEvidenceInput{
ID: "f-objective",
Kind: "file",
Summary: "implementation inspected",
Source: "file",
Paths: []string{"internal/control/controller.go"},
Accepted: true,
})
if err != nil {
t.Fatalf("RecordAutoResearchEvidence objective_evidence: %v", err)
}
err = c.RecordAutoResearchEvidence("verification", AutoResearchEvidenceInput{
ID: "f-verification",
Kind: "test",
Summary: "go test passed",
Source: "command",
Command: "go test ./internal/control",
Accepted: true,
})
if err != nil {
t.Fatalf("RecordAutoResearchEvidence verification: %v", err)
}
report, err := c.autoResearch.Readiness(taskID)
if err != nil {
t.Fatalf("Readiness: %v", err)
}
if !report.Ready {
t.Fatalf("readiness = %+v, want ready", report)
}
}
func TestAutoResearchEvidenceDoesNotChangeDefaultToolSurface(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
reg := tool.NewRegistry()
New(Options{WorkspaceRoot: root, Registry: reg})
if _, ok := reg.Get("autoresearch_record_evidence"); ok {
t.Fatalf("autoresearch_record_evidence should not be registered in the default provider-visible tool surface; tools=%v", reg.Names())
}
for _, schema := range reg.Schemas() {
if schema.Name == "autoresearch_record_evidence" {
t.Fatalf("autoresearch_record_evidence should not appear in provider schemas: %+v", reg.Schemas())
}
}
}
func TestResearchGoalCompletionMarksAutoResearchTaskComplete(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn(`Done.
<autoresearch-evidence>
{"criterion_id":"objective_evidence","id":"f-objective","kind":"file","summary":"The implementation state was inspected directly.","source":"file","paths":["internal/control/controller.go"],"accepted":true}
</autoresearch-evidence>
<autoresearch-evidence>
{"criterion_id":"verification","id":"f-verification","kind":"test","summary":"The focused AutoResearch tests passed.","source":"command","command":"go test ./internal/control","accepted":true}
</autoresearch-evidence>
[goal:complete]`),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
c.SetGoalWithResearchMode("verify completion lifecycle", GoalResearchOn)
taskID := c.goals.currentAutoResearchTaskID()
if taskID == "" {
t.Fatal("expected autoresearch task id")
}
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
}
summary, err := c.autoResearch.Summary(taskID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Status != "complete" {
t.Fatalf("AutoResearch status = %q, want complete", summary.Status)
}
if summary.StaleCount != 0 {
t.Fatalf("AutoResearch stale_count = %d, want 0 after accepted evidence", summary.StaleCount)
}
findings, err := c.autoResearch.Findings(taskID, 0)
if err != nil {
t.Fatalf("Findings: %v", err)
}
if len(findings) != 2 {
t.Fatalf("findings = %+v, want two assistant evidence records", findings)
}
if !containsNotice(notices, "autoresearch task completed") {
t.Fatalf("notices = %+v, want autoresearch task completed", notices)
}
}
func TestResearchGoalBlockedMarksAutoResearchTaskBlocked(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Blocked.\n\n[goal:blocked:needs credentials]"),
textTurn("Still blocked.\n\n[goal:blocked:needs credentials]"),
textTurn("Still blocked.\n\n[goal:blocked:needs credentials]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
c.SetGoalWithResearchMode("verify blocked lifecycle", GoalResearchOn)
taskID := c.goals.currentAutoResearchTaskID()
if taskID == "" {
t.Fatal("expected autoresearch task id")
}
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
}
summary, err := c.autoResearch.Summary(taskID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Status != "blocked" || !strings.Contains(summary.Blocker, "needs credentials") {
t.Fatalf("AutoResearch summary = %+v, want blocked with reason", summary)
}
if !containsNotice(notices, "AutoResearch task marked blocked.") {
t.Fatalf("notices = %+v, want autoresearch blocked notice", notices)
}
}
func TestPlainInputAutoStartDoesNotMutateGoalWhenTurnRunning(t *testing.T) {
c := New(Options{})
c.mu.Lock()
c.running = true
c.mu.Unlock()
c.Submit("持续排查这个线上卡顿直到根因明确,并验证修复")
if got := c.Goal(); got != "" {
t.Fatalf("rejected concurrent auto-start should not set goal, got %q", got)
}
if got := c.GoalStatus(); got != GoalStatusStopped {
t.Fatalf("GoalStatus() = %q, want stopped", got)
}
}
func TestPlainInputWithWeakResearchSignalDoesNotAutoStartGoal(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Here is a normal answer."),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 4)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone {
events <- e
}
}),
})
c.Submit("长期来看这个模块怎么优化?")
waitForTurnDone(t, events)
first := firstUserMessage(ag.Session().Messages)
if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") {
t.Fatalf("weak ordinary prompt should not auto-start AutoResearch:\n%s", first)
}
if got := c.GoalStatus(); got != GoalStatusStopped {
t.Fatalf("GoalStatus() = %q, want stopped", got)
}
}
func TestCancelStopsIdleGoalWithIncompleteTodos(t *testing.T) {
ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
ag.SeedTodoState([]evidence.TodoItem{{Content: "finish the migration", Status: "in_progress"}})
c := New(Options{Executor: ag, Sink: event.Discard})
c.SetGoalWithResearchMode("finish the migration", GoalResearchOn)
c.Cancel()
if got := c.GoalStatus(); got != GoalStatusStopped {
t.Fatalf("GoalStatus() = %q, want stopped", got)
}
if got := c.Goal(); got != "finish the migration" {
t.Fatalf("Goal() = %q, want stopped goal text to remain for display/persistence", got)
}
if todos := c.Todos(); len(todos) != 1 || todos[0].Status != "in_progress" {
t.Fatalf("Todos() after stopping idle goal = %+v, want incomplete todo retained", todos)
}
}
func TestGoalRepeatedBlockedStopsAfterThreeTurns(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Blocked.\n\n[goal:blocked: Needs credentials.]"),
textTurn("Still blocked.\n\n[goal:blocked:needs-credentials]"),
textTurn("Still blocked.\n\n[goal:blocked:NEEDS CREDENTIALS]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal deploy the service")
waitForTurnDone(t, events)
if prov.call != 3 {
t.Fatalf("provider calls = %d, want 3 blocked attempts", prov.call)
}
if got := c.GoalStatus(); got != GoalStatusBlocked {
t.Fatalf("GoalStatus() = %q, want blocked", got)
}
}
func TestGoalRestartResetsBlockedAudit(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Blocked.\n\n[goal:blocked:needs credentials]"),
textTurn("Blocked again.\n\n[goal:blocked:needs credentials]"),
textTurn("Blocked third time.\n\n[goal:blocked:needs credentials]"),
textTurn("Fresh blocked audit.\n\n[goal:blocked:needs credentials]"),
textTurn("Recovered on retry.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 12)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal deploy the service")
waitForTurnDone(t, events)
if got := c.GoalStatus(); got != GoalStatusBlocked {
t.Fatalf("first run GoalStatus() = %q, want blocked", got)
}
c.Submit("/goal deploy the service")
waitForTurnDone(t, events)
if prov.call != 5 {
t.Fatalf("provider calls = %d, want 5 (3 blocked + 2 resumed)", prov.call)
}
if got := c.GoalStatus(); got != GoalStatusComplete {
t.Fatalf("resumed GoalStatus() = %q, want complete; blocked audit should restart", got)
}
}
// TestIncompleteGoalTodos verifies that formatIncompleteTodos detects
// unfinished tasks and returns a formatted reminder, and returns empty
// when all todos are complete.
func TestIncompleteGoalTodos(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done")}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
c := New(Options{Runner: ag, Executor: ag, Sink: event.Discard})
reminder := func() string { return formatIncompleteTodos(c.goalTodos(), ag.GoalReadinessFailure()) }
// Seed with incomplete todos.
ag.SeedTodoState([]evidence.TodoItem{
{Content: "Fix the parser", Status: "in_progress"},
{Content: "Add tests", Status: "pending"},
})
msg := reminder()
if msg == "" {
t.Fatal("formatIncompleteTodos() returned empty string, expected reminder")
}
if !strings.Contains(msg, "Fix the parser") {
t.Fatalf("reminder should mention 'Fix the parser', got: %q", msg)
}
if !strings.Contains(msg, "Add tests") {
t.Fatalf("reminder should mention 'Add tests', got: %q", msg)
}
if !strings.Contains(msg, "todo_write") {
t.Fatalf("reminder should suggest updating todos via todo_write, got: %q", msg)
}
// Mark all complete.
ag.ReplaceTodoState([]evidence.TodoItem{
{Content: "Fix the parser", Status: "completed"},
{Content: "Add tests", Status: "completed"},
})
if got := reminder(); got != "" {
t.Fatalf("formatIncompleteTodos() with all-complete = %q, want empty", got)
}
// Empty todo list.
ag.ReplaceTodoState(nil)
if got := reminder(); got != "" {
t.Fatalf("formatIncompleteTodos() with empty list = %q, want empty", got)
}
}
// TestGoalInterceptsCompleteWithIncompleteTodos verifies that when the
// agent claims [goal:complete] but has unfinished canonical todos, the
// goal loop intercepts the first claim, then lets a second consecutive
// claim through as an override.
func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("All done.\n\n[goal:complete]"),
textTurn("All done.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
// Seed incomplete todos before starting.
ag.SeedTodoState([]evidence.TodoItem{
{Content: "Fix the parser", Status: "in_progress"},
})
notices := make(chan string, 64)
done := make(chan event.Event, 1)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
switch e.Kind {
case event.Notice:
notices <- e.Text
case event.TurnDone:
done <- e
}
}),
})
c.Submit("/goal fix everything")
<-done // wait for the entire goal loop to finish
close(notices)
// Collect all notices.
var allNotices []string
for n := range notices {
allNotices = append(allNotices, n)
}
// Should see an intercept notice and the goal should complete
// (second [goal:complete] overrides the intercept).
found := false
for _, n := range allNotices {
if strings.Contains(n, "Goal still has unfinished task state") {
found = true
break
}
}
if !found {
t.Fatalf("expected an unfinished-goal notice, got %v", allNotices)
}
if c.GoalStatus() != GoalStatusComplete {
t.Fatalf("GoalStatus() = %q, want complete (second [goal:complete] should override)", c.GoalStatus())
}
}
// TestGoalOverrideCompletesRemainingTodos verifies that when the goal
// completes via the second [goal:complete] override (non-strict mode), any
// remaining incomplete canonical todos are force-completed and a synthetic
// todo_write event is emitted so the frontend panel reflects the final state.
func TestGoalOverrideCompletesRemainingTodos(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("All done.\n\n[goal:complete]"),
textTurn("All done.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
ag.SeedTodoState([]evidence.TodoItem{
{Content: "Step 1", Status: "in_progress"},
{Content: "Step 2", Status: "pending"},
})
var tools []event.Event
done := make(chan event.Event, 1)
c := New(Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
switch e.Kind {
case event.ToolDispatch, event.ToolResult:
tools = append(tools, e)
case event.TurnDone:
done <- e
}
}),
})
c.Submit("/goal do everything")
<-done // wait for the goal loop to finish
if c.GoalStatus() != GoalStatusComplete {
t.Fatalf("GoalStatus() = %q, want complete", c.GoalStatus())
}
// All todos in the executor must be force-completed.
for _, td := range c.executor.CanonicalTodoState() {
if td.Status != "completed" {
t.Fatalf("canonical todo %q = %s, want completed after goal override", td.Content, td.Status)
}
}
// Must have emitted a synthetic todo_write (ToolDispatch + ToolResult)
// from completeRemainingGoalTodos.
hasSynthetic := false
for _, e := range tools {
if e.Tool.ID == "goal-final" && e.Tool.Name == "todo_write" {
hasSynthetic = true
break
}
}
if !hasSynthetic {
t.Fatal("expected synthetic todo_write event (ID=goal-final) after goal override completion")
}
}
// TestCompleteRemainingGoalTodosEdgeCases verifies that the helper is a no-op
// when there are no incomplete todos or no todos at all.
func TestCompleteRemainingGoalTodosEdgeCases(t *testing.T) {
t.Run("empty todo list does nothing", func(t *testing.T) {
ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
c := New(Options{Executor: ag, Sink: event.Discard})
c.completeRemainingGoalTodos()
if len(ag.CanonicalTodoState()) != 0 {
t.Fatal("expected no changes to empty todo list")
}
})
t.Run("all completed does nothing", func(t *testing.T) {
ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
ag.SeedTodoState([]evidence.TodoItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "completed"},
})
var events []event.Event
c := New(Options{
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
events = append(events, e)
}),
})
c.completeRemainingGoalTodos()
if len(events) > 0 {
t.Fatalf("expected no events when all todos already completed, got %d", len(events))
}
})
t.Run("force-completes mixed todos", func(t *testing.T) {
ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
ag.SeedTodoState([]evidence.TodoItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "in_progress"},
{Content: "C", Status: "pending"},
})
var captured []event.Event
c := New(Options{
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ToolDispatch || e.Kind == event.ToolResult {
captured = append(captured, e)
}
}),
})
c.completeRemainingGoalTodos()
// All must be completed.
for _, td := range ag.CanonicalTodoState() {
if td.Status != "completed" {
t.Fatalf("todo %q = %s, want completed", td.Content, td.Status)
}
}
// Must include a ToolDispatch+ToolResult for the synthetic todo_write.
if len(captured) != 2 {
t.Fatalf("expected 2 synthetic events (dispatch+result), got %d", len(captured))
}
if captured[0].Kind != event.ToolDispatch || captured[0].Tool.Name != "todo_write" {
t.Fatalf("first event should be ToolDispatch for todo_write, got %+v", captured[0].Kind)
}
if captured[1].Kind != event.ToolResult || captured[1].Tool.Name != "todo_write" {
t.Fatalf("second event should be ToolResult for todo_write, got %+v", captured[1].Kind)
}
})
t.Run("empty-string status treated as incomplete", func(t *testing.T) {
ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
ag.SeedTodoState([]evidence.TodoItem{
{Content: "A", Status: ""},
{Content: "B", Status: "completed"},
})
c := New(Options{Executor: ag, Sink: event.Discard})
c.completeRemainingGoalTodos()
for _, td := range ag.CanonicalTodoState() {
if td.Status != "completed" {
t.Fatalf("empty-string todo %q should be force-completed, got %q", td.Content, td.Status)
}
}
})
}
// TestStrictGoalBlocksRepeatedComplete verifies that in strict mode, every
// [goal:complete] with incomplete todos is intercepted — no override allowed.
func TestStrictGoalBlocksRepeatedComplete(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Done!\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
ag.SeedTodoState([]evidence.TodoItem{
{Content: "Fix the parser", Status: "in_progress"},
})
c := New(Options{Runner: ag, Executor: ag, Sink: event.Discard})
c.Submit("/goal --strict fix everything")
// In strict mode the agent still has incomplete todos but only one
// turn was given (the provider recycles it). The goal loop keeps
// intercepting; when the turn-recycling hits maxGoalAutoTurns (50)
// the goal is blocked. Verify it's not "complete".
if c.GoalStatus() == GoalStatusComplete {
t.Fatal("strict mode should not allow goal completion with incomplete todos")
}
}
func readJSONFileForTest(t *testing.T, path string, out any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
if err := json.Unmarshal(data, out); err != nil {
t.Fatalf("Unmarshal(%s): %v", path, err)
}
}
func sessionContainsUserText(messages []provider.Message, needles ...string) bool {
for _, msg := range messages {
if msg.Role != provider.RoleUser {
continue
}
ok := true
for _, needle := range needles {
if !strings.Contains(msg.Content, needle) {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
func containsNotice(notices []string, needle string) bool {
for _, notice := range notices {
if strings.Contains(notice, needle) {
return true
}
}
return false
}
// TestSessionRotationClearsActiveGoal pins the /new & /clear goal semantics:
// a fresh session starts with no active goal (so the old goal's text stops
// injecting into its first turns), while the OLD session's persisted
// goal-state sidecar keeps the running goal so resuming it restores the goal.
func TestSessionRotationClearsActiveGoal(t *testing.T) {
dir := t.TempDir()
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
oldPath := filepath.Join(dir, "session.jsonl")
c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test"})
c.SetGoal("ship the release checklist")
if got := c.Goal(); got != "ship the release checklist" {
t.Fatalf("Goal() = %q after SetGoal", got)
}
if composed := c.Compose("hello"); !strings.Contains(composed, "<active-goal>") {
t.Fatalf("running goal should inject into turns, composed = %q", composed)
}
if err := c.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
if got := c.Goal(); got != "" {
t.Fatalf("Goal() after /new = %q, want empty", got)
}
if composed := c.Compose("hello"); strings.Contains(composed, "<active-goal>") {
t.Fatalf("old goal leaked into the fresh session's turn: %q", composed)
}
// The old session keeps its running goal on disk for /resume.
oldState, err := os.ReadFile(store.SessionGoalState(oldPath))
if err != nil {
t.Fatalf("read old goal state: %v", err)
}
if !strings.Contains(string(oldState), "ship the release checklist") || !strings.Contains(string(oldState), GoalStatusRunning) {
t.Fatalf("old session's goal state was disturbed by /new: %s", oldState)
}
// The new session's sidecar records the cleared (stopped) state, so
// profile restores read it as "no running goal".
newState, err := os.ReadFile(store.SessionGoalState(c.SessionPath()))
if err != nil {
t.Fatalf("read new goal state: %v", err)
}
if strings.Contains(string(newState), "ship the release checklist") {
t.Fatalf("new session's goal state carries the old goal: %s", newState)
}
// Same contract for /clear.
c.SetGoal("another goal")
if err := c.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
if got := c.Goal(); got != "" {
t.Fatalf("Goal() after /clear = %q, want empty", got)
}
if composed := c.Compose("hello"); strings.Contains(composed, "<active-goal>") {
t.Fatalf("old goal leaked into the cleared session's turn: %q", composed)
}
}