fix(control): give the approved-plan execution turn its own user message id (#10647)

executeApprovedPlan runs the execution turn in the planning turn's
context, which carries the user message identity reserved for that turn.
The agent admits the host "plan approved" message under that identity, so
the session holds two messages with the user's id. The durable
message/complete is refused as a duplicate, and a later full
history/replace of the in-memory session can write the duplicate to disk,
after which the session can no longer be hydrated.

runComposedSyntheticTurn now reserves a fresh identity for the synthetic
message it admits.
This commit is contained in:
yhh committed 2026-09-25 04:58:51 -07:00
1 parent 77aeb94fe0
commit bb6b7f6081
2 files changed
+50

No files matched your search

@@ -0,0 +1,46 @@
package control
import (
"context"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// The approved-plan execution turn runs in the planning turn's context, which
// carries the user message identity the controller reserved for that turn.
func TestApprovedPlanExecutionMintsItsOwnUserMessageID(t *testing.T) {
prov := &scriptedTurns{turns: planThenExecuteTurns("Plan:\n1. Change it", "Done.")}
ag := newPlanTestAgent(prov)
approvalID := make(chan string, 1)
c := newOwnedTestController(t, Options{
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvalID <- e.Approval.ID
}
}),
})
c.SetPlanMode(true)
go func() { c.Approve(<-approvalID, true, false, false) }()
if err := c.runTurnWithRaw(context.Background(), "change it", "change it"); err != nil {
t.Fatalf("runTurnWithRaw: %v", err)
}
users := 0
seen := map[string]bool{}
for _, message := range ag.Session().Messages {
if message.Role == provider.RoleUser {
users++
}
if seen[message.ID] {
t.Fatalf("message id %q appears twice (second as %s/%s)", message.ID, message.Role, message.Origin)
}
seen[message.ID] = true
}
if users < 2 {
t.Fatalf("user messages = %d, want the request and the approved-plan continuation", users)
}
}
+4
View File
@@ -54,6 +54,10 @@ func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text st
c := o.c
ctx = agent.WithRawUserInput(ctx, text)
ctx = withTurnInputOrigin(ctx, true)
// ctx may carry the identity already spent on the turn's own user message.
if c.executor != nil {
ctx = agent.WithUserMessageIdentity(ctx, c.executor.Session(), agent.NewMessageID())
}
ctx = c.withTurnContext(ctx, false)
ctx = c.withPlannerTurnMetadata(ctx, text, true, c.messageCount())
return c.runModelTurn(ctx, c.ComposeSynthetic(text))