Merge pull request #6961 from erphenheimer/refactor/6958-intercept-output-model

refactor(goal): bind FSM intercept outputs to Goal lifecycle / 让 FSM 拦截输出绑定 Goal 生命周期
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-31 00:49:34 +08:00
commit 8ccf5bb468
5 files changed
+386 -61

No files matched your search

+91 -31
View File
@@ -46,11 +46,11 @@ type goalMachine struct {
turns int
blocks int
block string
interceptMsg string
intercepts int
strict bool
selfCheckDone bool
idleTurns int
continuationEpoch uint64
// statePath is the persisted goal-state sidecar; empty disables persistence.
statePath string
@@ -86,7 +86,6 @@ type goalMachineSnapshot struct {
turns int
blocks int
block string
interceptMsg string
intercepts int
strict bool
selfCheckDone bool
@@ -96,22 +95,35 @@ type goalMachineSnapshot struct {
// goalAdvanceInput carries everything the FSM needs for one continuation step,
// gathered by the caller off the machine's lock.
type goalAdvanceInput struct {
status string // parsed marker status ("" when the turn carried no marker)
reason string // blocked reason from the marker, if any
toolCalled bool // whether the last turn made any tool call
todos []evidence.TodoItem
readiness string // executor.GoalReadinessFailure()
status string // parsed marker status ("" when the turn carried no marker)
reason string // blocked reason from the marker, if any
toolCalled bool // whether the last turn made any tool call
todos []evidence.TodoItem
readiness string // executor.GoalReadinessFailure()
expectedEpoch *uint64 // owning turn's lifecycle; nil for direct FSM calls
}
// goalAdvanceResult reports the FSM step's outcome. data/path/ok describe the
// state to persist (built under mu when something changed); notice is surfaced
// to the user; cont reports whether the goal loop should continue.
type goalAdvanceResult struct {
notice string
cont bool
path string
data []byte
ok bool
notice string
intercept string
cont bool
continuationEpoch uint64
path string
data []byte
ok bool
}
// goalContinuationSnapshot binds a continuation to the exact Goal lifecycle
// state admitted for its synthetic turn. The orchestrator uses these captured
// fields throughout the turn instead of re-reading a possibly replaced Goal.
type goalContinuationSnapshot struct {
goal string
researchMode GoalResearchMode
autoResearchTaskID string
scopeID string
}
// goalStatePath derives a session's persisted goal-state sidecar.
@@ -132,7 +144,7 @@ func (g *goalMachine) capture() goalMachineSnapshot {
goal: g.goal, status: g.status, researchMode: g.researchMode,
autoResearchTaskID: g.autoResearchTaskID, scopeID: g.scopeID,
deliveryCheckpoint: g.deliveryCheckpoint, turns: g.turns,
blocks: g.blocks, block: g.block, interceptMsg: g.interceptMsg,
blocks: g.blocks, block: g.block,
intercepts: g.intercepts, strict: g.strict,
selfCheckDone: g.selfCheckDone, idleTurns: g.idleTurns,
}
@@ -144,8 +156,9 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
g.autoResearchTaskID, g.scopeID = snapshot.autoResearchTaskID, snapshot.scopeID
g.deliveryCheckpoint, g.turns = snapshot.deliveryCheckpoint, snapshot.turns
g.blocks, g.block = snapshot.blocks, snapshot.block
g.interceptMsg, g.intercepts = snapshot.interceptMsg, snapshot.intercepts
g.intercepts = snapshot.intercepts
g.strict, g.selfCheckDone, g.idleTurns = snapshot.strict, snapshot.selfCheckDone, snapshot.idleTurns
g.continuationEpoch++
g.mu.Unlock()
}
@@ -171,6 +184,15 @@ func (g *goalMachine) currentAutoResearchTaskID() string {
return g.autoResearchTaskID
}
// continuationToken captures the Goal lifecycle that owns an outgoing turn.
// The matching assistant output may advance the FSM only while this epoch is
// still current.
func (g *goalMachine) continuationToken() uint64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.continuationEpoch
}
func (g *goalMachine) deliveryScope() (id, task string, ok bool) {
g.mu.Lock()
defer g.mu.Unlock()
@@ -218,8 +240,9 @@ func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID
if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID {
return "", nil, false
}
g.continuationEpoch++
g.turns, g.blocks, g.block = 0, 0, ""
g.interceptMsg, g.intercepts = "", 0
g.intercepts = 0
g.selfCheckDone, g.idleTurns, g.strict = false, 0, false
if goal == "" {
g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, ""
@@ -245,10 +268,10 @@ func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string,
func (g *goalMachine) stop(status string, todos []evidence.TodoItem) (string, []byte, bool) {
g.mu.Lock()
defer g.mu.Unlock()
g.continuationEpoch++
if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
g.status = status
}
g.interceptMsg = ""
g.intercepts = 0
g.selfCheckDone = false
g.idleTurns = 0
@@ -261,9 +284,10 @@ func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byt
if strings.TrimSpace(g.goal) == "" || g.status == GoalStatusComplete {
return "", nil, false, false
}
g.continuationEpoch++
g.status = GoalStatusRunning
g.blocks, g.block = 0, ""
g.interceptMsg, g.intercepts = "", 0
g.intercepts = 0
g.selfCheckDone, g.idleTurns = false, 0
if g.scopeID == "" {
g.scopeID = newGoalScopeID()
@@ -288,16 +312,43 @@ func (g *goalMachine) deliveryState() evidence.DeliveryCheckpoint {
return g.deliveryCheckpoint
}
// takeIntercept consumes a pending continuation-turn override, if any.
func (g *goalMachine) takeIntercept() (string, bool) {
// acceptContinuation checks an advance result before the orchestrator surfaces
// its notice. admitContinuation revalidates after synchronous notice callbacks
// and captures the Goal state at the synthetic-turn admission boundary.
func (g *goalMachine) acceptContinuation(res goalAdvanceResult) (string, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.interceptMsg == "" {
if !res.cont ||
res.continuationEpoch != g.continuationEpoch ||
strings.TrimSpace(g.goal) == "" ||
g.status != GoalStatusRunning {
return "", false
}
msg := g.interceptMsg
g.interceptMsg = ""
return msg, true
return res.intercept, true
}
// admitContinuation atomically validates an advance result and captures the
// Goal state used to compose and scope its synthetic turn. Keeping validation
// and capture in one critical section prevents a stale intercept from being
// paired with a replacement Goal between those operations.
func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuationSnapshot, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if !res.cont ||
res.continuationEpoch != g.continuationEpoch ||
strings.TrimSpace(g.goal) == "" ||
g.status != GoalStatusRunning {
return goalContinuationSnapshot{}, false
}
if g.scopeID == "" {
g.scopeID = newGoalScopeID()
}
return goalContinuationSnapshot{
goal: g.goal,
researchMode: g.researchMode,
autoResearchTaskID: g.autoResearchTaskID,
scopeID: g.scopeID,
}, true
}
// advance runs one continuation step of the goal FSM from already-gathered
@@ -306,25 +357,30 @@ func (g *goalMachine) takeIntercept() (string, bool) {
func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
g.mu.Lock()
defer g.mu.Unlock()
if in.expectedEpoch != nil && *in.expectedEpoch != g.continuationEpoch {
return goalAdvanceResult{cont: false}
}
if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
return goalAdvanceResult{cont: false}
}
g.continuationEpoch++
g.turns++
var notice string
var intercept string
switch in.status {
case GoalStatusComplete:
if incomplete := formatIncompleteTodos(in.todos, in.readiness); len(incomplete) > 0 && (g.strict || g.intercepts == 0) {
// In strict mode every claim is blocked until todos are done;
// otherwise only the first consecutive claim is intercepted.
g.intercepts++
g.interceptMsg = incomplete
intercept = incomplete
break
}
// Todos are all done — in strict mode run self-check before final
// completion. Non-strict mode completes immediately.
if g.strict && !g.selfCheckDone {
g.selfCheckDone = true
g.interceptMsg = goalSelfCheckTurn
intercept = goalSelfCheckTurn
break
}
// Self-check passed — complete the goal.
@@ -335,7 +391,6 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
g.status = GoalStatusComplete
g.blocks = 0
g.block = ""
g.interceptMsg = ""
notice = goalCompleteNotice
case GoalStatusBlocked:
g.idleTurns = 0
@@ -363,14 +418,14 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
// Idle detection: if the agent went multiple turns without any tool calls,
// inject a reminder to make progress (unless the goal is already completing
// or hitting the auto-turn limit).
if notice == "" && g.interceptMsg == "" {
if notice == "" && intercept == "" {
if in.toolCalled {
g.idleTurns = 0
} else {
g.idleTurns++
if g.idleTurns >= maxGoalIdleTurns {
g.idleTurns = 0
g.interceptMsg = "No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>]."
intercept = "No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>]."
}
}
}
@@ -379,11 +434,15 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
g.block = "goal continuation limit reached"
g.intercepts = 0
g.selfCheckDone = false
g.interceptMsg = ""
g.idleTurns = 0
notice = g.block
}
res := goalAdvanceResult{notice: notice, cont: notice == ""}
res := goalAdvanceResult{
notice: notice,
intercept: intercept,
cont: notice == "",
continuationEpoch: g.continuationEpoch,
}
if notice != "" {
res.path, res.data, res.ok = g.buildStateLocked(in.todos)
}
@@ -531,8 +590,9 @@ func (g *goalMachine) restoreFromState(sessionPath string) {
g.blocks = state.Blocks
g.block = state.Block
g.strict = state.Strict
g.interceptMsg, g.intercepts = "", 0
g.intercepts = 0
g.selfCheckDone, g.idleTurns = false, 0
g.continuationEpoch++
}
// formatIncompleteTodos renders the reminder shown when [goal:complete] arrives
+59 -2
View File
@@ -771,8 +771,8 @@ func TestGoalBlockedSignalDoesNotTriggerIdleIntercept(t *testing.T) {
if !res.cont {
t.Fatal("first blocked signal should keep the goal audit running")
}
if msg, ok := g.takeIntercept(); ok {
t.Fatalf("blocked signal triggered idle intercept %q", msg)
if res.intercept != "" {
t.Fatalf("blocked signal triggered idle intercept %q", res.intercept)
}
}
@@ -913,6 +913,63 @@ func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) {
}
}
func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
newResult := func(t *testing.T, g *goalMachine) goalAdvanceResult {
t.Helper()
g.set("old goal", GoalResearchAuto, "", nil)
res := g.advance(goalAdvanceInput{
status: GoalStatusComplete,
toolCalled: true,
todos: []evidence.TodoItem{{
Content: "unfinished work from old goal",
Status: "in_progress",
}},
})
if res.intercept == "" {
t.Fatal("test setup: expected an incomplete-todo intercept")
}
return res
}
t.Run("current result is accepted", func(t *testing.T) {
var g goalMachine
res := newResult(t, &g)
if got, ok := g.acceptContinuation(res); !ok || got != res.intercept {
t.Fatalf("acceptContinuation() = (%q, %v), want current intercept", got, ok)
}
})
t.Run("replacement goal invalidates result", func(t *testing.T) {
var g goalMachine
res := newResult(t, &g)
g.set("replacement goal", GoalResearchAuto, "", nil)
if got, ok := g.acceptContinuation(res); ok {
t.Fatalf("replacement goal accepted stale intercept %q", got)
}
})
t.Run("stop and resume invalidates result", func(t *testing.T) {
var g goalMachine
res := newResult(t, &g)
g.stop(GoalStatusStopped, nil)
if _, _, _, resumed := g.resume(nil); !resumed {
t.Fatal("test setup: goal did not resume")
}
if got, ok := g.acceptContinuation(res); ok {
t.Fatalf("resumed goal accepted stale intercept %q", got)
}
})
t.Run("newer advance invalidates result", func(t *testing.T) {
var g goalMachine
res := newResult(t, &g)
g.advance(goalAdvanceInput{toolCalled: true})
if got, ok := g.acceptContinuation(res); ok {
t.Fatalf("newer FSM step accepted stale intercept %q", got)
}
})
}
// 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
+19 -1
View File
@@ -138,13 +138,31 @@ func (c *Controller) Compose(text string) string {
}
func (c *Controller) compose(text, source string, includeHookContext bool) string {
goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot()
return c.composeWithGoal(
text,
source,
includeHookContext,
goal,
goalStatus,
goalResearchMode,
autoResearchTaskID,
)
}
func (c *Controller) composeWithGoal(
text, source string,
includeHookContext bool,
goal, goalStatus string,
goalResearchMode GoalResearchMode,
autoResearchTaskID string,
) string {
c.mu.Lock()
plan := c.planMode
responseLanguage := c.responseLanguage
reasoningLanguage := c.reasoningLanguage
c.mu.Unlock()
notes := c.memory.drainPending()
goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot()
if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
prefix := activeGoalBlock(goal, goalResearchMode)
+84 -27
View File
@@ -25,11 +25,12 @@ type turnOrchestrator struct {
}
type orchestratedTurn struct {
input string
raw string
display string
editedOriginal string
synthetic bool
input string
raw string
display string
editedOriginal string
synthetic bool
goalContinuation *goalContinuationSnapshot
}
func newTurnOrchestrator(c *Controller) *turnOrchestrator {
@@ -48,6 +49,25 @@ func (o *turnOrchestrator) runSyntheticTurnWithRawDisplay(ctx context.Context, i
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, synthetic: true})
}
func (o *turnOrchestrator) runGoalContinuationTurnWithRawDisplay(
ctx context.Context,
input, raw, display string,
res goalAdvanceResult,
) (bool, error) {
snapshot, ok := o.c.goals.admitContinuation(res)
if !ok {
return false, nil
}
err := o.runOrchestratedTurn(ctx, orchestratedTurn{
input: input,
raw: raw,
display: display,
synthetic: true,
goalContinuation: &snapshot,
})
return true, err
}
func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text string) error {
c := o.c
ctx = agent.WithRawUserInput(ctx, text)
@@ -63,13 +83,14 @@ func (o *turnOrchestrator) runSubagentSkillGoalLoop(ctx context.Context, sk skil
}
func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
if err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode); err != nil {
if ctx.Err() != nil {
o.c.stopGoal(GoalStatusStopped)
}
return err
}
return o.continueGoal(ctx)
return o.continueGoal(ctx, expectedContinuationEpoch)
}
// runSubagentSkillTurns records the composed user task and distilled child
@@ -163,7 +184,21 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
userImages := c.inputImages(turn.input)
ctx = agent.WithUserImages(ctx, userImages)
ctx = agent.WithRawUserInput(ctx, turn.raw)
input := c.compose(turn.input, turn.raw, !turn.synthetic)
continuation := turn.goalContinuation
var input string
if continuation != nil {
input = c.composeWithGoal(
turn.input,
turn.raw,
false,
continuation.goal,
GoalStatusRunning,
continuation.researchMode,
continuation.autoResearchTaskID,
)
} else {
input = c.compose(turn.input, turn.raw, !turn.synthetic)
}
startMessages := c.messageCount()
defer c.snapshotActivityIfChanged(startMessages)
defer c.recordDisplayForNewUser(startMessages, turn.display)
@@ -195,14 +230,24 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
}
c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
autoResearchTaskID := c.goals.currentAutoResearchTaskID()
var autoResearchTaskID string
if continuation != nil {
autoResearchTaskID = continuation.autoResearchTaskID
} else {
autoResearchTaskID = c.goals.currentAutoResearchTaskID()
}
autoResearchAcceptedBefore := c.autoResearchAcceptedEvidenceIDs(autoResearchTaskID)
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "")
modelInput := input
if !turn.synthetic {
modelInput = c.withCapabilityRoute(input, turn.raw)
}
if scopeID, task, ok := c.goals.deliveryScope(); ok {
if continuation != nil {
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{
ID: continuation.scopeID,
TaskText: continuation.goal,
})
} else if scopeID, task, ok := c.goals.deliveryScope(); ok {
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: task})
}
ctx = c.withPlannerTurnMetadata(ctx, turn.raw, turn.synthetic, startMessages)
@@ -302,6 +347,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
}
func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
if err := o.runTurnWithRawDisplay(ctx, input, raw, display); err != nil {
if ctx.Err() != nil {
o.c.stopGoal(GoalStatusStopped)
@@ -310,10 +356,11 @@ func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input,
}
return err
}
return o.continueGoal(ctx)
return o.continueGoal(ctx, expectedContinuationEpoch)
}
func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
if err := o.runEditedTurnWithRawDisplay(ctx, input, raw, display, original); err != nil {
if ctx.Err() != nil {
o.c.stopGoal(GoalStatusStopped)
@@ -322,7 +369,7 @@ func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context,
}
return err
}
return o.continueGoal(ctx)
return o.continueGoal(ctx, expectedContinuationEpoch)
}
// goalShouldBlockOnError reports host pauses that permanently block a Goal
@@ -335,36 +382,45 @@ func goalShouldBlockOnError(err error) bool {
return errors.As(err, &readiness)
}
func (o *turnOrchestrator) continueGoal(ctx context.Context) error {
func (o *turnOrchestrator) continueGoal(ctx context.Context, expectedContinuationEpoch uint64) error {
c := o.c
for {
cont := o.advanceGoalAfterTurn()
if !cont {
res := o.advanceGoalAfterTurn(expectedContinuationEpoch)
if !res.cont {
return nil
}
if err := ctx.Err(); err != nil {
c.stopGoal(GoalStatusStopped)
return err
}
intercept, ok := c.goals.acceptContinuation(res)
if !ok {
return nil
}
turn := goalContinueTurn
if msg, ok := c.goals.takeIntercept(); ok {
turn = msg
if strings.Contains(msg, "AutoResearch readiness check failed") {
c.noticeDetail("Goal is not ready to complete yet; continuing the remaining work.", msg)
if intercept != "" {
turn = intercept
if strings.Contains(intercept, "AutoResearch readiness check failed") {
c.noticeDetail("Goal is not ready to complete yet; continuing the remaining work.", intercept)
} else {
c.noticeDetail("Goal still has unfinished task state; continuing the remaining work.", msg)
c.noticeDetail("Goal still has unfinished task state; continuing the remaining work.", intercept)
}
}
if err := o.runSyntheticTurnWithRawDisplay(ctx, turn, turn, ""); err != nil {
admitted, err := o.runGoalContinuationTurnWithRawDisplay(ctx, turn, turn, "", res)
if err != nil {
if ctx.Err() != nil {
c.stopGoal(GoalStatusStopped)
}
return err
}
if !admitted {
return nil
}
expectedContinuationEpoch = res.continuationEpoch
}
}
func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
func (o *turnOrchestrator) advanceGoalAfterTurn(expectedContinuationEpoch uint64) goalAdvanceResult {
c := o.c
// Gather every input the FSM needs off the goal lock: parse the marker,
// snapshot the executor's todos + readiness, and check tool activity. None
@@ -383,11 +439,12 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
}
}
res := c.goals.advance(goalAdvanceInput{
status: status,
reason: reason,
toolCalled: c.toolWasCalledLastTurn(),
todos: c.goalTodos(),
readiness: readiness,
status: status,
reason: reason,
toolCalled: c.toolWasCalledLastTurn(),
todos: c.goalTodos(),
readiness: readiness,
expectedEpoch: &expectedContinuationEpoch,
})
c.persistGoalState(res.path, res.data, res.ok)
if res.notice != "" {
@@ -397,7 +454,7 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
if res.notice == goalCompleteNotice && c.executor != nil {
c.completeRemainingGoalTodos()
}
return res.cont
return res
}
func (c *Controller) finalizeAutoResearchTask(taskID, notice string) {
+133
View File
@@ -24,6 +24,25 @@ type plannerMetadataRunner struct {
input string
}
type goalReplacingRunner struct {
c *Controller
executor *agent.Agent
calls int
}
func (r *goalReplacingRunner) Run(context.Context, string) error {
r.calls++
if r.calls == 1 {
r.c.SetGoal("replacement goal")
r.executor.ReplaceTodoState(nil)
r.executor.Session().Add(provider.Message{
Role: provider.RoleAssistant,
Content: "Old Goal turn finished.\n\n[goal:complete]",
})
}
return nil
}
func (r *plannerMetadataRunner) Run(ctx context.Context, input string) error {
r.meta, _ = plannerTurnMetadataFromContext(ctx)
r.input = input
@@ -102,6 +121,120 @@ func TestTurnOrchestratorTypedSyntheticTurnDoesNotDependOnPrefix(t *testing.T) {
}
}
func TestGoalTurnOutputCannotAdvanceReplacementGoal(t *testing.T) {
executor := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
runner := &goalReplacingRunner{executor: executor}
c := New(Options{
Runner: runner,
Executor: executor,
SessionDir: t.TempDir(),
})
runner.c = c
c.SetGoal("old goal")
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(
context.Background(),
"work on the old goal",
"work on the old goal",
"",
); err != nil {
t.Fatal(err)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1 old-Goal turn", runner.calls)
}
if got := c.Goal(); got != "replacement goal" {
t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
}
if got := c.GoalStatus(); got != GoalStatusRunning {
t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
}
}
func TestGoalContinuationNoticeCannotMoveOldInterceptIntoReplacementGoal(t *testing.T) {
runner := &fakeTurnRunner{}
session := agent.NewSession("")
session.Add(provider.Message{
Role: provider.RoleAssistant,
Content: "All done.\n\n[goal:complete]",
})
executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard)
executor.SeedTodoState([]evidence.TodoItem{{
Content: "unfinished work from old goal",
Status: "in_progress",
}})
var c *Controller
replaced := false
c = New(Options{
Runner: runner,
Executor: executor,
Sink: event.FuncSink(func(e event.Event) {
if replaced ||
e.Kind != event.Notice ||
!strings.Contains(e.Text, "Goal still has unfinished task state") {
return
}
replaced = true
c.SetGoal("replacement goal")
}),
})
c.SetGoal("old goal")
if err := newTurnOrchestrator(c).continueGoal(
context.Background(),
c.goals.continuationToken(),
); err != nil {
t.Fatal(err)
}
if !replaced {
t.Fatal("test setup: Notice callback did not replace the active Goal")
}
if got := c.Goal(); got != "replacement goal" {
t.Fatalf("Goal() = %q, want replacement goal", got)
}
if len(runner.inputs) != 0 {
t.Fatalf("stale continuation reached runner with input %q", runner.inputs[0])
}
}
func TestGoalContinuationOutputCannotAdvanceReplacementGoal(t *testing.T) {
session := agent.NewSession("system")
session.Add(provider.Message{
Role: provider.RoleAssistant,
Content: "All done.\n\n[goal:complete]",
})
executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard)
executor.SeedTodoState([]evidence.TodoItem{{
Content: "unfinished work from old goal",
Status: "in_progress",
}})
runner := &goalReplacingRunner{executor: executor}
c := New(Options{
Runner: runner,
Executor: executor,
SessionDir: t.TempDir(),
})
runner.c = c
c.SetGoal("old goal")
if err := newTurnOrchestrator(c).continueGoal(
context.Background(),
c.goals.continuationToken(),
); err != nil {
t.Fatal(err)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1 old-Goal continuation", runner.calls)
}
if got := c.Goal(); got != "replacement goal" {
t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
}
if got := c.GoalStatus(); got != GoalStatusRunning {
t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
}
}
func TestTurnOrchestratorStopHookIgnoresCanceledTurnContext(t *testing.T) {
runCtx, cancel := context.WithCancel(context.Background())
var stopCalls int