fix(agent): preserve trusted task text in completion contracts

Problem:
The first completion-receipt fix used raw user input unconditionally, which did not preserve Goal scope task text or pristine sub-agent classifier text. Its explanatory comment also exceeded repository lint budgets.

Root cause:
Completion contracts and delivery classification selected their task source independently even though both require the same host-trusted text.

Fix:
Store the already-selected trusted classification input as the per-turn contract input. Add regressions for Goal continuations and host-wrapped sub-agents, and remove the lint-expanding comment.

Verification:
- go run ./tools/repolint
- go test -race ./internal/agent -run TestCompletionContract -count=1
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go vet ./...
- go test ./...
- cd desktop && go test ./...
- git diff --check
This commit is contained in:
SivanCola committed 2026-08-11 12:11:32 +08:00
1 parent 95e4be0205
commit b0fb0fbcde
2 files changed
+44 -12

No files matched your search

+8 -12
View File
@@ -113,11 +113,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// A fresh user turn starts from zeroed per-turn host state; the new turn's
// values are computed below. Cross-turn state (checkpoint, scope, failure
// budgets) lives directly on Agent and is reconciled field by field.
// Host-composed input can contain transient capability, memory, and hook
// blocks. Keep the authenticated user text as the contract source so those
// implementation details never become acceptance criteria or completion
// receipt text. The provider still receives the composed input below.
a.perTurnState = perTurnState{turnInput: rawInput}
a.perTurnState = perTurnState{}
a.resetStructuralRunGuards()
scope, scoped := DeliveryExecutionScopeFromContext(ctx)
preserveEvidence := a.preserveEvidenceOnce
@@ -182,17 +178,17 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// deadlocked read-only subagents. Without the override the raw input is
// classified verbatim: stripping user-controllable markup here would let
// input dressed up as host framing disarm the delivery gates.
classifierInput := a.classifierTaskText
a.turnInput = a.classifierTaskText
if scoped && strings.TrimSpace(scope.TaskText) != "" {
classifierInput = scope.TaskText
} else if strings.TrimSpace(classifierInput) == "" {
classifierInput = rawInput
a.turnInput = scope.TaskText
} else if strings.TrimSpace(a.turnInput) == "" {
a.turnInput = rawInput
}
intent := taskintent.Classify(classifierInput)
intent := taskintent.Classify(a.turnInput)
a.deliveryTaskExpected = intent.NeedsEvidence()
a.deliveryMutationExpected = intent == taskintent.Mutation && registryHasWriterTools(a.tools)
a.deliveryPersistentExpected = taskintent.NeedsPersistentAction(classifierInput)
a.recoveryTaskSummary = boundedRecoveryTaskSummary(classifierInput)
a.deliveryPersistentExpected = taskintent.NeedsPersistentAction(a.turnInput)
a.recoveryTaskSummary = boundedRecoveryTaskSummary(a.turnInput)
// A cancelled/error turn leaves a provider-excluded recovery record at the
// transcript tail. Fold its bounded facts into this new user turn exactly
// once; the user's raw text remains the classifier source above.
+36
View File
@@ -119,6 +119,42 @@ Policy: prefer means use the skill for the required change
}
}
func TestCompletionContractUsesGoalScopeTaskText(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{}, event.Discard)
ctx := WithRawUserInput(context.Background(), "Continue working.")
ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "goal-1", TaskText: "fix the parser"})
if err := a.Run(ctx, "<goal-context>continue</goal-context>"); err != nil {
t.Fatalf("Run: %v", err)
}
assertAtomicCriterion(t, a, "fix the parser")
}
func TestCompletionContractUsesPristineSubagentTaskText(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{
ClassifierTaskText: "fix the parser",
}, event.Discard)
const wrapped = "<workspace-context>private host framing</workspace-context>\n\nfix the parser"
if err := a.Run(context.Background(), wrapped); err != nil {
t.Fatalf("Run: %v", err)
}
assertAtomicCriterion(t, a, "fix the parser")
}
func assertAtomicCriterion(t *testing.T, a *Agent, want string) {
t.Helper()
receipt := a.CompletionReceipt()
if receipt == nil || len(receipt.Gaps) == 0 {
t.Fatalf("completion receipt = %+v, want atomic criterion %q", receipt, want)
}
if got := receipt.Gaps[0].Detail; got != "r1: "+want {
t.Fatalf("atomic criterion = %q, want %q", got, want)
}
}
func TestSubagentImageCandidatesAreCopiedAndIsolated(t *testing.T) {
images := []string{"data:image/png;base64,AAAA"}
ctx := WithSubagentImageCandidates(context.Background(), images)