mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* Reduce compaction context while preserving task state * Fix durable compaction evidence identity * Preserve structured tool errors during compaction * Unify manual and automatic compaction * Report compaction truncation structurally * Preserve automatic compaction metadata
267 lines
13 KiB
Go
267 lines
13 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/trace"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
|
events := []taskStateEvent{
|
|
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
|
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
|
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
|
{kind: taskStateEventVerification, verification: OutcomePassed},
|
|
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
|
}
|
|
|
|
first := newTaskState("ship the change", nil)
|
|
second := newTaskState("ship the change", nil)
|
|
for _, event := range events {
|
|
first.observe(event)
|
|
second.observe(event)
|
|
}
|
|
|
|
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
|
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
|
}
|
|
got := first.snapshot()
|
|
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
|
t.Fatalf("unexpected plan snapshot: %#v", got)
|
|
}
|
|
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
|
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
|
}
|
|
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
|
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
|
}
|
|
}
|
|
|
|
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
|
recorder := trace.NewRecorder("session", "run", "")
|
|
state := newTaskState("objective", recorder)
|
|
for range 20 {
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
|
}
|
|
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
|
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
|
}
|
|
|
|
recorder = trace.NewRecorder("session", "run-2", "")
|
|
state = newTaskState("objective", recorder)
|
|
for range 20 {
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
|
}
|
|
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
|
events := recorder.Finish().TaskStates
|
|
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
|
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
|
}
|
|
}
|
|
|
|
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(tools.NewUpdatePlanTool())
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
recorder := trace.NewRecorder("session", "run", "")
|
|
|
|
result, err := Run(context.Background(), "implement the change", provider, Options{
|
|
Registry: registry,
|
|
Trace: recorder,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if result.FinalAnswer != "done" {
|
|
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
|
}
|
|
events := recorder.Finish().TaskStates
|
|
if len(events) < 3 {
|
|
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
|
}
|
|
last := events[len(events)-1]
|
|
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
|
t.Fatalf("unexpected final task snapshot: %#v", last)
|
|
}
|
|
}
|
|
|
|
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
|
state := newTaskState("objective", nil)
|
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
|
|
|
snapshot := state.snapshot()
|
|
snapshot.Plan.Items[0].Content = "mutated"
|
|
snapshot.ChangedFiles[0] = "mutated.go"
|
|
|
|
fresh := state.snapshot()
|
|
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
|
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
|
}
|
|
}
|
|
|
|
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
|
|
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
|
|
arguments := `{"cmd":"go test ./internal/agent"}`
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
|
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
|
|
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
|
|
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
|
|
}})
|
|
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
|
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
|
|
Scope: "/tmp", DecisionReason: "write a temporary fixture",
|
|
}})
|
|
|
|
snapshot := state.snapshot()
|
|
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
|
|
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
|
|
}
|
|
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
|
|
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
|
|
}
|
|
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
|
|
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
|
|
}
|
|
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
|
|
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
|
|
}
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
|
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
|
|
}})
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
|
|
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
|
|
}})
|
|
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
|
|
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
|
|
}
|
|
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
|
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
|
|
}})
|
|
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
|
|
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
|
|
}
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
|
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
|
|
}})
|
|
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
|
|
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
|
|
}
|
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
|
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
|
|
}})
|
|
snapshot = state.snapshot()
|
|
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
|
|
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
|
|
}
|
|
for _, key := range snapshot.ResolvedFailureKeys {
|
|
if key == snapshot.UnresolvedFailures[0].Key {
|
|
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
|
state := newTaskState("objective", nil)
|
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
|
|
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
|
}}}
|
|
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
|
t.Fatalf("parity = %q, want match", parity)
|
|
}
|
|
|
|
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
|
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
|
t.Fatalf("parity = %q, want mismatch", parity)
|
|
}
|
|
}
|
|
|
|
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
|
state := newTaskState("objective", nil)
|
|
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
|
|
|
snapshot := state.snapshot()
|
|
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
|
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
|
}
|
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
|
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
|
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
|
}
|
|
|
|
empty := newTaskState("objective", nil)
|
|
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
|
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
|
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
|
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
|
}
|
|
}
|
|
|
|
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
|
state := newTaskState("objective", nil)
|
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
|
}}}
|
|
|
|
context := state.completionContext(messages, true)
|
|
if !context.PlanPending || context.PlanMatchesTranscript {
|
|
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
|
}
|
|
if context.Objective != "objective" {
|
|
t.Fatalf("objective lost from completion context: %#v", context)
|
|
}
|
|
}
|
|
|
|
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
|
state := newTaskState("objective", nil)
|
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
|
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
|
}}}
|
|
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
|
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
|
}
|
|
|
|
mismatching := append([]zeroruntime.Message(nil), matching...)
|
|
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
|
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
|
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
|
}
|
|
}
|
|
|
|
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
|
for _, arguments := range []string{
|
|
`{}`,
|
|
`{"plan":null}`,
|
|
`{"plan":[{"step":"alias is not accepted"}]}`,
|
|
`{"plan":[{"content":""}]}`,
|
|
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
|
`{"plan":[{"id":4,"content":"valid"}]}`,
|
|
} {
|
|
if plan, ok := parseTaskPlan(arguments); ok {
|
|
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
|
}
|
|
}
|
|
}
|