diff --git a/internal/agent/compaction.go b/internal/agent/compaction.go index 9acff708b..3a19dcdaf 100644 --- a/internal/agent/compaction.go +++ b/internal/agent/compaction.go @@ -38,8 +38,9 @@ const compactionTriggerRatio = 0.7 // transcript (and so tests can assert on it). const summaryLabel = "[Summary of earlier conversation]" -// summaryInstructions is the system prompt handed to the summarizer model. -const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " + +// CompactionSummaryInstructions is the system prompt handed to both automatic +// and manually requested compaction summarizers. +const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " + "Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " + "decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " + "results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " + @@ -75,6 +76,12 @@ type CompactionResult struct { // summary message also includes summaryLabel and any preserved structured // state needed by later compactions. SummaryText string + // ProjectedChars is the text content sent to the summarizer after semantic + // projection, excluding provider protocol framing. + ProjectedChars int + // Truncated reports whether projection dropped content to satisfy its + // bounded brief or previous-summary limits. + Truncated bool // Compacted reports whether Messages contains an injected summary. Compacted bool } @@ -216,11 +223,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co }, nil } - summary, err := opts.Summarize(middle) + summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize) if err != nil { return CompactionResult{}, err } - summary = strings.TrimSpace(summary) + summary := summaryResult.SummaryText // Preserve structured state (active plan + loaded skills) from the elided // middle verbatim, so it is not lost or paraphrased away by the prose summary. @@ -238,10 +245,53 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co RemovedCount: len(middle), PreservedCount: len(messages) - len(middle), SummaryText: summary, + ProjectedChars: summaryResult.ProjectedChars, + Truncated: summaryResult.Truncated, Compacted: true, }, nil } +// CompactionSummaryResult describes the shared summarization input and output. +type CompactionSummaryResult struct { + SummaryText string + ProjectedChars int + Truncated bool +} + +// SummarizeCompactionMessages applies the shared semantic projection before +// invoking a caller-supplied summarizer. Automatic context-pressure compaction +// and manual session compaction both use this path. +func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) { + if summarize == nil { + return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function") + } + projection := projectCompactionInput(messages) + summaryInput := projection.messages + if len(summaryInput) == 0 { + summaryInput = messages + } + summary, err := summarize(summaryInput) + if err != nil { + return CompactionSummaryResult{}, err + } + return CompactionSummaryResult{ + SummaryText: strings.TrimSpace(summary), + ProjectedChars: compactionMessageChars(summaryInput), + Truncated: projection.truncated, + }, nil +} + +func compactionMessageChars(messages []zeroruntime.Message) int { + total := 0 + for _, message := range messages { + total += len(message.Content) + for _, call := range message.ToolCalls { + total += len(call.Name) + len(call.Arguments) + } + } + return total +} + // safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so // the preserved suffix begins on a user or assistant message rather than a // tool/tool_result message. A tool result with no preceding assistant tool call @@ -576,7 +626,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) { request := zeroruntime.CompletionRequest{ Messages: []zeroruntime.Message{ - {Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions}, + {Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions}, {Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)}, }, // No tools: this is a plain text summarization call. diff --git a/internal/agent/compaction_metadata_test.go b/internal/agent/compaction_metadata_test.go index 37bc6a405..ba226a7d2 100644 --- a/internal/agent/compaction_metadata_test.go +++ b/internal/agent/compaction_metadata_test.go @@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) { if result.SummaryText != "manual summary" { t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText) } - if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" { - t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured) + if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 { + t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured)) + } + if result.Truncated { + t.Fatal("Truncated = true for a projection within its limits") + } + if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") { + t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured) } if len(result.Messages) != 4 { t.Fatalf("compacted message count = %d, want 4", len(result.Messages)) @@ -96,11 +102,46 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) { if result.SummaryText != "" { t.Fatalf("SummaryText = %q, want empty", result.SummaryText) } + if result.ProjectedChars != 0 || result.Truncated { + t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated) + } if !reflect.DeepEqual(result.Messages, messages) { t.Fatalf("Messages changed on no-op: %#v", result.Messages) } } +func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) { + messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}} + for index := range 20 { + messages = append(messages, zeroruntime.Message{ + Role: zeroruntime.MessageRoleUser, + Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)), + }) + } + messages = append(messages, + zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"}, + zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"}, + ) + + var captured []zeroruntime.Message + result, err := CompactMessages(messages, CompactionOptions{ + PreserveLast: 2, + Summarize: func(toSummarize []zeroruntime.Message) (string, error) { + captured = append([]zeroruntime.Message(nil), toSummarize...) + return "bounded summary", nil + }, + }) + if err != nil { + t.Fatal(err) + } + if !result.Compacted || !result.Truncated { + t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated) + } + if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 { + t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured)) + } +} + func TestCompactMessagesPropagatesSummarizeError(t *testing.T) { messages := []zeroruntime.Message{ {Role: zeroruntime.MessageRoleSystem, Content: "system"}, diff --git a/internal/agent/compaction_preserve.go b/internal/agent/compaction_preserve.go index 0e38ef5f5..fa36db76a 100644 --- a/internal/agent/compaction_preserve.go +++ b/internal/agent/compaction_preserve.go @@ -102,7 +102,7 @@ type skillEntry struct { body string } -// recentEdits returns the files mutated by write_file/edit_file calls in messages +// recentEdits returns files mutated by editing calls in messages // — latest note per path, in last-seen order — as skillEntry{name: path, body: // note}. After compaction elides the editing turns, this tells the model WHAT it // changed in each file (from the tool's result) so it need not re-read to @@ -113,6 +113,7 @@ type skillEntry struct { // rather than an earlier, now-stale entry. func recentEdits(messages []zeroruntime.Message) []skillEntry { pathByID := map[string]string{} + noteByPath := map[string]string{} sequence := make([]string, 0) for _, message := range messages { for _, call := range message.ToolCalls { @@ -129,12 +130,24 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry { sequence = append(sequence, path) } } + for _, message := range messages { + if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 { + continue + } + for _, path := range message.ChangedFiles { + path = strings.TrimSpace(path) + if path == "" { + continue + } + sequence = append(sequence, path) + noteByPath[path] = editNote(message.Content) + } + } order := lastSeenOrder(sequence) if len(order) == 0 { return nil } - noteByPath := map[string]string{} for _, message := range messages { if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" { continue @@ -425,15 +438,20 @@ type preservedState struct { } type preservedTaskState struct { - Objective string `json:"objective"` - Status taskStatus `json:"status,omitempty"` - Pending int `json:"pending,omitempty"` - InProgress int `json:"in_progress,omitempty"` - Completed int `json:"completed,omitempty"` - Failed int `json:"failed,omitempty"` - VerificationPassed int `json:"verification_passed,omitempty"` - VerificationFailed int `json:"verification_failed,omitempty"` - VerificationOutcome Outcome `json:"verification_outcome,omitempty"` + Objective string `json:"objective"` + Status taskStatus `json:"status,omitempty"` + Pending int `json:"pending,omitempty"` + InProgress int `json:"in_progress,omitempty"` + Completed int `json:"completed,omitempty"` + Failed int `json:"failed,omitempty"` + VerificationPassed int `json:"verification_passed,omitempty"` + VerificationFailed int `json:"verification_failed,omitempty"` + VerificationOutcome Outcome `json:"verification_outcome,omitempty"` + Constraints []string `json:"constraints,omitempty"` + ChangedFiles []string `json:"changed_files,omitempty"` + UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"` + Approvals []taskApprovalState `json:"approvals,omitempty"` + Artifacts []taskArtifactState `json:"artifacts,omitempty"` } type preservedEdit struct { @@ -468,7 +486,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap task := priorState.Task if taskSnapshot != nil { task = &preservedTaskState{ - Objective: capTaskObjective(taskSnapshot.Objective), + Objective: capTaskObjective(taskSnapshot.Objective), + Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints), + ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries), + UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys), + Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries), + Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries), } // Plan parity corroborates only the mutable task projection. The objective // comes directly from the run prompt and is immutable, so it must survive @@ -484,6 +507,13 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap task.VerificationOutcome = taskSnapshot.Verification.LastOutcome } } + freshConstraints := explicitConstraintsFromMessages(middle) + if len(freshConstraints) > 0 { + if task == nil { + task = &preservedTaskState{} + } + task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints) + } // Plan: a fresh update_plan in middle is authoritative; otherwise carry the // plan preserved by an earlier compaction. @@ -517,6 +547,95 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap return summary } +func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string { + var constraints []string + for _, message := range messages { + if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) { + continue + } + if _, body := projectInstructionBlock(message.Content); body != "" { + continue + } + constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints) + } + return constraints +} + +func priorTaskConstraints(task *preservedTaskState) []string { + if task == nil { + return nil + } + return task.Constraints +} + +func priorTaskChangedFiles(task *preservedTaskState) []string { + if task == nil { + return nil + } + return task.ChangedFiles +} + +func priorTaskFailures(task *preservedTaskState) []taskFailureState { + if task == nil { + return nil + } + return task.UnresolvedFailures +} + +func priorTaskApprovals(task *preservedTaskState) []taskApprovalState { + if task == nil { + return nil + } + return task.Approvals +} + +func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState { + if task == nil { + return nil + } + return task.Artifacts +} + +func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState { + resolvedSet := make(map[string]struct{}, len(resolved)) + for _, key := range resolved { + resolvedSet[key] = struct{}{} + } + merged := make([]taskFailureState, 0, len(older)+len(newer)) + for _, failure := range append(append([]taskFailureState(nil), older...), newer...) { + if _, ok := resolvedSet[failure.Key]; ok { + continue + } + withoutSameKey := make([]taskFailureState, 0, len(merged)) + for _, existing := range merged { + if existing.Key != failure.Key { + withoutSameKey = append(withoutSameKey, existing) + } + } + merged = append(withoutSameKey, failure) + } + if len(merged) > maxTaskEvidenceEntries { + merged = merged[len(merged)-maxTaskEvidenceEntries:] + } + return merged +} + +func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T { + merged := make([]T, 0, len(older)+len(newer)) + seen := make(map[T]struct{}, len(older)+len(newer)) + for _, value := range append(append([]T(nil), older...), newer...) { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + merged = append(merged, value) + } + if len(merged) > limit { + merged = merged[len(merged)-limit:] + } + return merged +} + func capTaskObjective(objective string) string { objective = strings.TrimSpace(objective) if len(objective) <= maxTaskObjectiveBytes { diff --git a/internal/agent/compaction_preserve_test.go b/internal/agent/compaction_preserve_test.go index e85f04e52..ccc4e342e 100644 --- a/internal/agent/compaction_preserve_test.go +++ b/internal/agent/compaction_preserve_test.go @@ -6,6 +6,7 @@ import ( "testing" "unicode/utf8" + "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -64,6 +65,9 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) { objective := strings.Repeat("世", maxTaskObjectiveBytes) task := newTaskState(objective, nil) task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`}) + task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{ + Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"}, + }}) messages := stateConversation() compacted, err := Compact(messages, CompactionOptions{ PreserveLast: 2, @@ -80,6 +84,69 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) { if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) { t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective) } + if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" { + t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles) + } +} + +func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) { + task := newTaskState("Please keep the change focused.", nil) + task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{ + Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed", + Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"}, + }}) + task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{ + ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp", + }}) + messages := stateConversation() + + first, err := Compact(messages, CompactionOptions{ + PreserveLast: 2, + Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil }, + taskState: task.snapshotForCompaction(messages), + }) + if err != nil { + t.Fatal(err) + } + task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{ + Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence", + Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"}, + }}) + secondInput := append(append([]zeroruntime.Message{}, first...), + zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."}, + zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"}, + zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"}, + zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"}, + ) + second, err := Compact(secondInput, CompactionOptions{ + PreserveLast: 2, + Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil }, + taskState: task.snapshotForCompaction(secondInput), + }) + if err != nil { + t.Fatal(err) + } + state := parsePreservedStateBlock(second[1].Content) + if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 { + t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task) + } + if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" { + t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures) + } + for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} { + if !containsString(state.Task.Constraints, want) { + t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints) + } + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false } func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) { diff --git a/internal/agent/compaction_projection.go b/internal/agent/compaction_projection.go new file mode 100644 index 000000000..f5a62a401 --- /dev/null +++ b/internal/agent/compaction_projection.go @@ -0,0 +1,315 @@ +package agent + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +const ( + compactionUserWordBudget = 256 + compactionAssistantWordBudget = 200 + compactionToolCallsPerTurn = 8 + compactionToolArgumentBytes = 120 + compactionErrorBytes = 150 + compactionResultBytes = 1200 + compactionPreviousSummaryBytes = 16 * 1024 + compactionBriefMaxBytes = 24 * 1024 +) + +var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`) + +type compactionProjection struct { + messages []zeroruntime.Message + truncated bool +} + +// projectCompactionInput removes reconstructible tool output before the model +// summary call. It retains a bounded transcript of user intent, assistant +// decisions, tool actions, and concise errors. Exact durable state is appended +// separately from the original messages after the summary returns. +func projectCompactionInput(messages []zeroruntime.Message) compactionProjection { + sections := make([]string, 0, len(messages)) + previousSummary := "" + truncated := false + for index, message := range messages { + switch message.Role { + case zeroruntime.MessageRoleUser: + content := message.Content + if marker := strings.Index(content, preservedStateLabel); marker >= 0 { + content = content[:marker] + } + trimmed := strings.TrimSpace(content) + if strings.HasPrefix(trimmed, summaryLabel) { + fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel)) + previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes) + truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes + continue + } + if content = clipInformativeWords(content, compactionUserWordBudget); content != "" { + sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content)) + } + case zeroruntime.MessageRoleAssistant: + lines := make([]string, 0, 1+len(message.ToolCalls)) + if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" { + lines = append(lines, content) + } + calls := message.ToolCalls + omitted := 0 + if len(calls) > compactionToolCallsPerTurn { + omitted = len(calls) - compactionToolCallsPerTurn + calls = calls[omitted:] + } + if omitted > 0 { + lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted)) + } + for _, call := range calls { + lines = append(lines, compactionToolCallLine(call)) + } + if len(lines) > 0 { + sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n"))) + } + case zeroruntime.MessageRoleTool: + // New tool-result messages carry the execution status directly, as the + // source ToolResult does. Keep the text check for older session history + // created before Message exposed IsError. + name := toolNameForResult(messages, index) + mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0 + if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) { + continue + } + if name == "" { + name = "tool" + } + switch { + case message.IsError || isLikelyToolError(message.Content): + sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes))) + case name == "ask_user": + sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes))) + default: + sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name, + clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes))) + } + } + } + if len(sections) == 0 && previousSummary == "" { + return compactionProjection{} + } + brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n")) + truncated = truncated || briefTruncated + if previousSummary != "" { + brief = "[previous summary]\n" + previousSummary + "\n\n" + brief + } + return compactionProjection{ + messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}}, + truncated: truncated, + } +} + +func capCompactionBrief(brief string) (string, bool) { + if len(brief) <= compactionBriefMaxBytes { + return brief, false + } + marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n" + available := compactionBriefMaxBytes - len(marker) + // Keep both chronological edges: the oldest material has not necessarily + // been summarized before, while the newest material is most actionable. + headBytes := available * 2 / 5 + tailBytes := available - headBytes + head := clipPrefixAtBoundary(brief, headBytes) + tail := clipSuffixAtBoundary(brief, tailBytes) + return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true +} + +func compactionToolCallLine(call zeroruntime.ToolCall) string { + detail := "" + var arguments map[string]any + if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil { + for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} { + if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" { + detail = value + break + } + } + if detail == "" && call.Name == "ask_user" { + detail = askUserQuestionsDetail(arguments) + } + if detail == "" && call.Name == "apply_patch" { + if patch, ok := arguments["patch"].(string); ok { + detail = strings.Join(patchPaths(patch), ", ") + } + } + } + if detail == "" { + detail = commandFromArguments(call.Arguments) + } + if detail == "" { + return "* " + call.Name + } + return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes)) +} + +func isLikelyToolError(content string) bool { + prefix := strings.TrimSpace(content) + if len(prefix) > compactionErrorBytes { + prefix = prefix[:compactionErrorBytes] + } + lower := strings.ToLower(prefix) + for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func clipInformativeWords(text string, limit int) string { + flat := strings.Join(strings.Fields(text), " ") + if flat == "" || limit <= 0 { + return "" + } + count := 0 + for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) { + word := strings.ToLower(flat[location[0]:location[1]]) + if compactionStopWords[word] { + continue + } + count++ + if count > limit { + end := location[0] + return strings.TrimSpace(flat[:end]) + "...(truncated)" + } + } + return flat +} + +func clipBytes(text string, limit int) string { + text = strings.TrimSpace(text) + if limit <= 0 { + return "" + } + if len(text) <= limit { + return text + } + if limit <= 3 { + end := limit + for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 { + end-- + } + return text[:end] + } + end := limit - 3 + for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 { + end-- + } + return strings.TrimSpace(text[:end]) + "..." +} + +func clipHeadTailBytes(text string, limit int) string { + text = strings.TrimSpace(text) + if len(text) <= limit { + return text + } + if limit <= 0 { + return "" + } + marker := "\n...[middle omitted]...\n" + if limit <= len(marker) { + return clipBytes(text, limit) + } + available := limit - len(marker) + head := clipPrefixAtBoundary(text, available/2) + tail := clipSuffixAtBoundary(text, available-available/2) + return strings.TrimSpace(head) + marker + strings.TrimSpace(tail) +} + +func clipPrefixAtBoundary(text string, limit int) string { + if limit <= 0 { + return "" + } + if len(text) <= limit { + return text + } + end := limit + for end > 0 && text[end]&0xc0 == 0x80 { + end-- + } + if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 { + end = boundary + } + return text[:end] +} + +func clipSuffixAtBoundary(text string, limit int) string { + if limit <= 0 { + return "" + } + if len(text) <= limit { + return text + } + start := len(text) - limit + for start < len(text) && text[start]&0xc0 == 0x80 { + start++ + } + if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 { + start += boundary + 2 + } + return text[start:] +} + +func askUserQuestionsDetail(arguments map[string]any) string { + raw, ok := arguments["questions"].([]any) + if !ok { + return "" + } + questions := make([]string, 0, len(raw)) + for _, item := range raw { + object, ok := item.(map[string]any) + if !ok { + continue + } + if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" { + questions = append(questions, strings.TrimSpace(question)) + } + } + return strings.Join(questions, " | ") +} + +func patchPaths(patch string) []string { + var paths []string + seen := map[string]bool{} + for _, line := range strings.Split(patch, "\n") { + for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} { + if !strings.HasPrefix(line, prefix) { + continue + } + path := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if path != "" && !seen[path] { + seen[path] = true + paths = append(paths, path) + } + } + } + return paths +} + +var compactionStopWords = map[string]bool{ + "a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true, + "be": true, "been": true, "being": true, "have": true, "has": true, "had": true, + "do": true, "does": true, "did": true, "will": true, "would": true, "could": true, + "should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true, + "to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true, + "by": true, "from": true, "as": true, "into": true, "through": true, "during": true, + "before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true, + "and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true, + "both": true, "either": true, "neither": true, "each": true, "every": true, "all": true, + "any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true, + "that": true, "this": true, "these": true, "those": true, "it": true, "its": true, + "i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true, + "he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true, + "who": true, "which": true, "what": true, "if": true, "then": true, "than": true, + "when": true, "where": true, "how": true, "just": true, "also": true, +} diff --git a/internal/agent/compaction_projection_test.go b/internal/agent/compaction_projection_test.go new file mode 100644 index 000000000..31f46c466 --- /dev/null +++ b/internal/agent/compaction_projection_test.go @@ -0,0 +1,130 @@ +package agent + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) { + for _, limit := range []int{0, 1, 2, 3} { + if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) { + t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped) + } + } +} + +func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) { + for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} { + for _, limit := range []int{-1, 0} { + if got := clip("世界", limit); got != "" { + t.Fatalf("clipper limit %d = %q, want empty", limit, got) + } + } + } +} + +func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) { + largeBody := strings.Repeat("recoverable source line\n", 1000) + messages := []zeroruntime.Message{ + {Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."}, + {Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}}, + {Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody}, + {Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}}, + {Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"}, + } + + projection := projectCompactionInput(messages) + projected := projection.messages + if len(projected) != 1 { + t.Fatalf("projection = %#v, want one compact message", projected) + } + content := projected[0].Content + for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} { + if !strings.Contains(content, want) { + t.Fatalf("projection missing %q: %q", want, content) + } + } + if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") { + t.Fatalf("projection retained reconstructible or overlong tool output: %q", content) + } + if estimateTokens(projected)*20 >= estimateTokens(messages) { + t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected)) + } +} + +func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) { + messages := []zeroruntime.Message{ + {Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}}, + {Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true}, + } + + projected := projectCompactionInput(messages).messages + if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") { + t.Fatalf("structured tool error was dropped from projection: %#v", projected) + } +} + +func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) { + messages := []zeroruntime.Message{ + {Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}}, + {Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."}, + {Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{ + {ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`}, + {ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`}, + {ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`}, + }}, + {Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}}, + } + + content := projectCompactionInput(messages).messages[0].Content + for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} { + if !strings.Contains(content, want) { + t.Fatalf("projection missing %q: %q", want, content) + } + } +} + +func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) { + calls := make([]zeroruntime.ToolCall, 12) + for index := range calls { + calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`} + } + projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages + content := projected[0].Content + if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") { + t.Fatalf("tool-call tail was not bounded: %q", content) + } +} + +func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) { + previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision" + messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}} + for index := range 500 { + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)}) + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))}) + } + + projection := projectCompactionInput(messages) + content := projection.messages[0].Content + if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") { + t.Fatalf("previous summary was lost when the brief tail was capped: %q", content) + } + if !strings.Contains(content, "middle transcript omitted to fit compaction budget") { + t.Fatalf("oversized brief was not head/tail capped: %q", content) + } + if !projection.truncated { + t.Fatal("projection did not structurally report truncation") + } +} + +func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) { + projection := projectCompactionInput([]zeroruntime.Message{{ + Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]", + }}) + if projection.truncated { + t.Fatal("literal user text produced a false truncation signal") + } +} diff --git a/internal/agent/compaction_recent_edits_test.go b/internal/agent/compaction_recent_edits_test.go index c8fe2b73d..a776abd1b 100644 --- a/internal/agent/compaction_recent_edits_test.go +++ b/internal/agent/compaction_recent_edits_test.go @@ -20,11 +20,15 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) { {ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`}, }}, {Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"}, + {Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{ + {ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`}, + }}, + {Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}}, } edits := recentEdits(messages) - if len(edits) != 2 { - t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits) + if len(edits) != 3 { + t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits) } if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") { t.Fatalf("first edit = %#v, want foo.go with its note", edits[0]) @@ -32,6 +36,9 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) { if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") { t.Fatalf("second edit = %#v, want bar.go with its note", edits[1]) } + if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" { + t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2]) + } } // After compaction elides the editing turns, the preserved-state block still diff --git a/internal/agent/compaction_test.go b/internal/agent/compaction_test.go index 1086909cd..2b38e5ea8 100644 --- a/internal/agent/compaction_test.go +++ b/internal/agent/compaction_test.go @@ -58,12 +58,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) { if last.Content != "most recent question" { t.Fatalf("expected most recent message preserved, got %q", last.Content) } - // The summarized middle excludes system and preserved suffix. - if len(captured) != 3 { - t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured) - } - if captured[0].Content != "first question" { - t.Fatalf("expected oldest non-system message first, got %#v", captured[0]) + // The summarizer receives a compact semantic projection of the middle rather + // than reconstructible raw tool output. + if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser { + t.Fatalf("expected one projected summary input, got %#v", captured) + } + for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} { + if !strings.Contains(captured[0].Content, want) { + t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content) + } } // Compaction must shrink the conversation. if estimateTokens(result) >= estimateTokens(messages) { diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 516a905f3..4991fd8e0 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -187,6 +187,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) guards := newGuardState() task := newTaskState(prompt, options.Trace) + onPermission := options.OnPermission + options.OnPermission = func(event PermissionEvent) { + task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event}) + if onPermission != nil { + onPermission(event) + } + } compactor := newCompactionState(options, task) defer func() { // A final transcript comparison is observational. It records drift but @@ -698,7 +705,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) } options.Trace.Counter(trace.CounterToolCalls, 1) recordOutputBudgetTrace(options.Trace, toolResult) - task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult}) + task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult}) if options.OnToolResult != nil { options.OnToolResult(toolResult) } @@ -712,9 +719,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) turnRequestedModel = toolResult.RequestedModel } messages = append(messages, zeroruntime.Message{ - Role: zeroruntime.MessageRoleTool, - Content: toolResult.Output, - ToolCallID: toolResult.ToolCallID, + Role: zeroruntime.MessageRoleTool, + Content: toolResult.Output, + ToolCallID: toolResult.ToolCallID, + IsError: toolResult.Status == tools.StatusError, + ChangedFiles: append([]string(nil), toolResult.ChangedFiles...), }) // Images ride a following USER message rather than the tool result // above. Every provider drops images on a tool-role message — @@ -3278,6 +3287,7 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag Role: zeroruntime.MessageRoleTool, Content: abortedToolResultNotice, ToolCallID: call.ID, + IsError: true, }) } return messages @@ -3308,6 +3318,9 @@ func copyMessages(messages []Message) []Message { if message.Reasoning != nil { copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...) } + if message.ChangedFiles != nil { + copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...) + } // Deep-copy image attachments (slice AND each Data byte slice) so the // raw image bytes are never aliased across history/request/result copies. copied[index].Images = zeroruntime.CloneImageBlocks(message.Images) diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 1aeba7071..86647da84 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -3592,6 +3592,11 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin if !strings.Contains(strings.ToLower(placeholder), "aborted") { t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder) } + for _, message := range result.Messages { + if message.ToolCallID == "flaky-2" && !message.IsError { + t.Fatalf("aborted placeholder must carry error status: %#v", message) + } + } // Every tool_use in the final assistant message must have a matching result. for _, message := range result.Messages { @@ -3606,6 +3611,33 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin } } +func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(alwaysFailingTool{}) + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"}, + {Type: zeroruntime.StreamEventDone}, + }, + {{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}}, + }} + + result, err := Run(context.Background(), "go", provider, Options{Registry: registry}) + if err != nil { + t.Fatal(err) + } + for _, message := range result.Messages { + if message.ToolCallID == "failed-call" { + if !message.IsError { + t.Fatalf("failed tool result lost its structured status: %#v", message) + } + return + } + } + t.Fatalf("failed tool result missing from message history: %#v", result.Messages) +} + type secretEmittingTool struct{ output string } func (t secretEmittingTool) Name() string { return "leak" } diff --git a/internal/agent/task_state.go b/internal/agent/task_state.go index 32905decb..e45fd8bc6 100644 --- a/internal/agent/task_state.go +++ b/internal/agent/task_state.go @@ -1,10 +1,14 @@ package agent import ( + "crypto/sha256" "encoding/json" + "fmt" "reflect" + "regexp" "sort" "strings" + "unicode/utf8" "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/trace" @@ -52,17 +56,41 @@ type taskVerificationState struct { LastOutcome Outcome `json:"last_outcome,omitempty"` } +type taskFailureState struct { + Key string `json:"key,omitempty"` + Tool string `json:"tool"` + Command string `json:"command,omitempty"` + Summary string `json:"summary"` +} + +type taskApprovalState struct { + Tool string `json:"tool"` + Decision PermissionDecisionAction `json:"decision,omitempty"` + Scope string `json:"scope,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type taskArtifactState struct { + Tool string `json:"tool"` + Path string `json:"path"` +} + type taskStateSnapshot struct { - Revision int `json:"revision"` - Objective string `json:"objective"` - Status taskStatus `json:"status"` - Plan taskPlanState `json:"plan"` - Tools taskToolState `json:"tools"` - Verification taskVerificationState `json:"verification"` - ChangedFiles []string `json:"changed_files,omitempty"` - CompletionDecision CompletionDecision `json:"completion_decision,omitempty"` - CompletionReason string `json:"completion_reason,omitempty"` - PlanParity taskPlanParity `json:"plan_parity"` + Revision int `json:"revision"` + Objective string `json:"objective"` + Status taskStatus `json:"status"` + Plan taskPlanState `json:"plan"` + Tools taskToolState `json:"tools"` + Verification taskVerificationState `json:"verification"` + ChangedFiles []string `json:"changed_files,omitempty"` + Constraints []string `json:"constraints,omitempty"` + UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"` + Approvals []taskApprovalState `json:"approvals,omitempty"` + Artifacts []taskArtifactState `json:"artifacts,omitempty"` + ResolvedFailureKeys []string `json:"-"` + CompletionDecision CompletionDecision `json:"completion_decision,omitempty"` + CompletionReason string `json:"completion_reason,omitempty"` + PlanParity taskPlanParity `json:"plan_parity"` } type taskStateEventKind int @@ -72,6 +100,7 @@ const ( taskStateEventToolResult taskStateEventVerification taskStateEventCompletion + taskStateEventPermission ) type taskStateEvent struct { @@ -80,6 +109,7 @@ type taskStateEvent struct { toolResult ToolResult verification Outcome completion completionEvaluation + permission PermissionEvent } // taskState is a deterministic projection of facts the loop already observes. @@ -101,6 +131,7 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState { }, changedFiles: map[string]struct{}{}, } + state.snapshotValue.Constraints = extractExplicitConstraints(objective) state.recorder = recorder return state } @@ -134,6 +165,7 @@ func (state *taskState) observe(event taskStateEvent) bool { } } state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles) + state.observeToolEvidence(event.arguments, event.toolResult) changed = true case taskStateEventVerification: if event.verification == OutcomeDisabled { @@ -160,6 +192,10 @@ func (state *taskState) observe(event taskStateEvent) bool { state.snapshotValue.Status = taskStatusActive } changed = true + case taskStateEventPermission: + state.markActive() + state.observePermission(event.permission) + changed = true } if changed { state.snapshotValue.Revision++ @@ -183,9 +219,180 @@ func (state *taskState) snapshot() taskStateSnapshot { snapshot := state.snapshotValue snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...) snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...) + snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...) + snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...) + snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...) + snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...) + snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...) return snapshot } +const ( + maxTaskEvidenceEntries = 8 + maxTaskEvidenceBytes = 320 + maxTaskConstraints = 10 +) + +func (state *taskState) observeToolEvidence(arguments string, result ToolResult) { + command := capTaskEvidence(commandFromArguments(arguments)) + key := taskFailureKey(result.Name, command, arguments) + if result.Status == tools.StatusOK { + if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) { + state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key) + state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries) + } + } else { + state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key) + state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key) + state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{ + Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)), + }, maxTaskEvidenceEntries) + } + for _, metaKey := range []string{"spill_path", "artifact_path"} { + if path := strings.TrimSpace(result.Meta[metaKey]); path != "" { + state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts, + taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries) + } + } +} + +func taskFailureKey(toolName, command, arguments string) string { + identity := command + if identity == "" { + canonical := []byte(strings.TrimSpace(arguments)) + var value any + if json.Unmarshal(canonical, &value) == nil { + if encoded, err := json.Marshal(value); err == nil { + canonical = encoded + } + } + digest := sha256.Sum256(canonical) + identity = fmt.Sprintf("args:%x", digest[:8]) + } + return strings.TrimSpace(toolName) + "\x00" + identity +} + +func (state *taskState) observePermission(event PermissionEvent) { + decision := event.DecisionAction + if decision == "" { + decision = PermissionDecisionAction(event.Action) + } + state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{ + Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope), + Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)), + }, maxTaskEvidenceEntries) +} + +func commandFromArguments(arguments string) string { + var object map[string]json.RawMessage + if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil { + return "" + } + for _, key := range []string{"cmd", "command", "script", "shell"} { + var command string + if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil { + return strings.TrimSpace(command) + } + } + return "" +} + +func firstLine(value string) string { + value = strings.TrimSpace(value) + if index := strings.IndexByte(value, '\n'); index >= 0 { + value = value[:index] + } + return value +} + +func capTaskEvidence(value string) string { + value = strings.TrimSpace(value) + if len(value) <= maxTaskEvidenceBytes { + return value + } + limit := maxTaskEvidenceBytes - 3 + for limit > 0 && !utf8.RuneStart(value[limit]) { + limit-- + } + return strings.TrimSpace(value[:limit]) + "..." +} + +var explicitConstraintPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`), + regexp.MustCompile(`(?i)\bdon'?t want\b`), + regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`), + regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`), + regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`), + regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`), +} + +func extractExplicitConstraints(text string) []string { + var constraints []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") { + continue + } + for _, pattern := range explicitConstraintPatterns { + if pattern.MatchString(line) { + constraints = appendBoundedUnique(constraints, line, maxTaskConstraints) + break + } + } + if len(constraints) == maxTaskConstraints { + break + } + } + return constraints +} + +func appendBounded[T any](values []T, value T, limit int) []T { + values = append(values, value) + if len(values) > limit { + values = values[len(values)-limit:] + } + return values +} + +func appendBoundedUnique[T comparable](values []T, value T, limit int) []T { + filtered := values[:0] + for _, existing := range values { + if existing != value { + filtered = append(filtered, existing) + } + } + return appendBounded(filtered, value, limit) +} + +func hasTaskFailure(values []taskFailureState, key string) bool { + for _, value := range values { + if value.Key == key { + return true + } + } + return false +} + +func removeTaskFailure(values []taskFailureState, key string) []taskFailureState { + out := values[:0] + for _, value := range values { + if value.Key != key { + out = append(out, value) + } + } + return out +} + +func removeString(values []string, target string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if value != target { + out = append(out, value) + } + } + return out +} + // observePlanParity compares only the latest plan projection with the plan tool // calls still present in messages. It mutates the snapshot and emits when the // parity value changes; objective, tool, and verification fields are not part of diff --git a/internal/agent/task_state_test.go b/internal/agent/task_state_test.go index de8a61bd5..db9d6f000 100644 --- a/internal/agent/task_state_test.go +++ b/internal/agent/task_state_test.go @@ -115,6 +115,68 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) { } } +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"}]}`}) diff --git a/internal/sessions/replay.go b/internal/sessions/replay.go index 915cc77f7..ba3e737d5 100644 --- a/internal/sessions/replay.go +++ b/internal/sessions/replay.go @@ -7,6 +7,7 @@ import ( "unicode/utf8" "github.com/Gitlawb/zero/internal/redaction" + "github.com/Gitlawb/zero/internal/zeroruntime" ) type EventRef struct { @@ -37,6 +38,7 @@ type RewindPlan struct { type CompactionOptions struct { PreserveLast int MaxPromptChars int + SkipPrompt bool } type CompactionPlan struct { @@ -47,8 +49,9 @@ type CompactionPlan struct { CompactableEvents []EventRef `json:"compactableEvents"` PreservedEvents []EventRef `json:"preservedEvents"` SummaryPrompt string `json:"summaryPrompt"` - PromptChars int `json:"promptChars"` - Truncated bool `json:"truncated,omitempty"` + // PromptChars counts prompt text content and excludes provider protocol framing. + PromptChars int `json:"promptChars"` + Truncated bool `json:"truncated,omitempty"` } type RecordCompactionInput struct { @@ -164,7 +167,11 @@ func (store *Store) PlanCompaction(sessionID string, options CompactionOptions) } compactable := events[:split] preserved := events[split:] - prompt, truncated := buildCompactionPrompt(compactable, maxPromptChars) + prompt := "" + truncated := false + if !options.SkipPrompt { + prompt, truncated = buildCompactionPrompt(compactable, maxPromptChars) + } return CompactionPlan{ SessionID: sessionID, PreserveLast: preserveLast, @@ -312,6 +319,88 @@ func buildCompactionPrompt(events []Event, maxChars int) (string, bool) { return prompt, false } +// CompactionMessages converts durable session events into the same normalized +// message shape used by the running agent's compaction pipeline. Provider usage +// and checkpoint blobs are omitted. Message/tool fields rely on the session +// writer's invariant that tool output is scrubbed before persistence; +// ask_user answers and generic event previews are redacted here. +func CompactionMessages(events []Event) []zeroruntime.Message { + messages := make([]zeroruntime.Message, 0, len(events)) + for _, event := range events { + var payload map[string]any + _ = json.Unmarshal(event.Payload, &payload) + stringField := func(key string) string { + value, _ := payload[key].(string) + return strings.TrimSpace(value) + } + switch event.Type { + case EventMessage: + role := strings.ToLower(stringField("role")) + content := stringField("content") + switch role { + case "user": + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: content}) + case "assistant": + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: content}) + case "ask_user_answers": + if answers, ok := payload["answers"]; ok { + encoded, _ := json.Marshal(answers) + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "User answers: " + redaction.RedactString(string(encoded), redaction.Options{})}) + } + default: + if content != "" { + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: content}) + } + } + case EventToolCall: + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ + ID: firstSessionString(stringField("toolCallId"), stringField("id")), Name: firstSessionString(stringField("name"), stringField("toolName")), Arguments: stringField("arguments"), + }}}) + case EventToolResult: + status := strings.ToLower(stringField("status")) + messages = append(messages, zeroruntime.Message{ + Role: zeroruntime.MessageRoleTool, ToolCallID: firstSessionString(stringField("toolCallId"), stringField("id")), + Content: stringField("output"), IsError: status != "" && status != "ok", ChangedFiles: sessionStringSlice(payload["changedFiles"]), + }) + case EventCompaction: + if summary := stringField("summary"); summary != "" { + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "[Summary of earlier conversation]\n" + summary}) + } + case EventProviderUsage, EventSessionCheckpoint: + continue + default: + preview := shapedPayloadPreview(event) + if strings.TrimSpace(preview) != "" && preview != "{}" { + messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("[%s] %s", event.Type, preview)}) + } + } + } + return messages +} + +func firstSessionString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func sessionStringSlice(value any) []string { + raw, ok := value.([]any) + if !ok { + return nil + } + values := make([]string, 0, len(raw)) + for _, item := range raw { + if text, ok := item.(string); ok && strings.TrimSpace(text) != "" { + values = append(values, strings.TrimSpace(text)) + } + } + return values +} + func shapedPayloadPreview(event Event) string { switch event.Type { case EventPermission, EventPermissionRequest, EventPermissionDecision: diff --git a/internal/sessions/replay_test.go b/internal/sessions/replay_test.go index dc7ee4b1b..7c3434bde 100644 --- a/internal/sessions/replay_test.go +++ b/internal/sessions/replay_test.go @@ -5,8 +5,27 @@ import ( "strings" "testing" "time" + + "github.com/Gitlawb/zero/internal/zeroruntime" ) +func TestCompactionMessagesPreservesInteractiveAndMutationEvidence(t *testing.T) { + events := []Event{ + {Type: EventToolCall, Payload: json.RawMessage(`{"id":"ask","name":"ask_user","arguments":"{\"questions\":[{\"question\":\"Which database?\"}]}"}`)}, + {Type: EventToolResult, Payload: json.RawMessage(`{"toolCallId":"ask","name":"ask_user","status":"ok","output":"Postgres only"}`)}, + {Type: EventToolCall, Payload: json.RawMessage(`{"id":"patch","name":"apply_patch","arguments":"{\"patch\":\"*** Update File: db.go\"}"}`)}, + {Type: EventToolResult, Payload: json.RawMessage(`{"toolCallId":"patch","name":"apply_patch","status":"ok","output":"Done!","changedFiles":["db.go"]}`)}, + } + + messages := CompactionMessages(events) + if len(messages) != 4 || messages[0].Role != zeroruntime.MessageRoleAssistant || messages[1].Role != zeroruntime.MessageRoleTool { + t.Fatalf("unexpected normalized compaction messages: %#v", messages) + } + if messages[1].Content != "Postgres only" || len(messages[3].ChangedFiles) != 1 || messages[3].ChangedFiles[0] != "db.go" { + t.Fatalf("interactive or mutation evidence was lost: %#v", messages) + } +} + func TestStorePlansRewindBySequence(t *testing.T) { store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{ time.Date(2026, 6, 6, 10, 0, 0, 0, time.UTC), @@ -130,6 +149,26 @@ func TestStorePlansCompactionWindow(t *testing.T) { } } +func TestStoreCanPlanCompactionWithoutBuildingLegacyPrompt(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "compactnoprompt"}) + if err != nil { + t.Fatal(err) + } + for _, content := range []string{"one", "two", "three"} { + if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": content}}); err != nil { + t.Fatal(err) + } + } + plan, err := store.PlanCompaction(session.SessionID, CompactionOptions{PreserveLast: 1, SkipPrompt: true}) + if err != nil { + t.Fatal(err) + } + if plan.CompactableCount != 2 || plan.SummaryPrompt != "" || plan.PromptChars != 0 || plan.Truncated { + t.Fatalf("unexpected prompt-free plan: %#v", plan) + } +} + func TestStoreCompactionShapesSensitivePermissionEvents(t *testing.T) { secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" store := NewStore(StoreOptions{RootDir: t.TempDir()}) diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 4534b6c5e..69a65b255 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -21,7 +21,6 @@ import ( var responseStyles = []string{"balanced", "concise", "explanatory", "review"} const tuiCompactionPreserveLast = 8 -const tuiCompactionMaxPromptChars = 8000 const compactStatusRowID = "compact/status" var compactFrames = []string{"⠂", "⠒", "⠲", "⠴"} @@ -842,8 +841,8 @@ func (m model) compactActiveSession() (model, CompactResult, error) { beforeEvents := append([]sessions.Event{}, m.sessionEvents...) beforeTokens := estimateTranscriptTokens(m.transcript) plan, err := m.sessionStore.PlanCompaction(m.activeSession.SessionID, sessions.CompactionOptions{ - PreserveLast: tuiCompactionPreserveLast, - MaxPromptChars: tuiCompactionMaxPromptChars, + PreserveLast: tuiCompactionPreserveLast, + SkipPrompt: true, }) if err != nil { return m, CompactResult{}, err @@ -857,13 +856,26 @@ func (m model) compactActiveSession() (model, CompactResult, error) { }, nil } - summary, err := m.summarizeCompactionPlan(plan) + rawEvents, err := m.sessionStore.ReadEvents(m.activeSession.SessionID) + if err != nil { + return m, CompactResult{}, fmt.Errorf("read session events for compaction: %w", err) + } + compactableEvents, err := sessionEventsForRefs(rawEvents, plan.CompactableEvents) + if err != nil { + return m, CompactResult{}, err + } + summaryResult, err := m.summarizeCompactionPlan(plan, sessions.CompactionMessages(compactableEvents)) if err != nil { return m, CompactResult{}, err } + // PromptChars counts the text content sent to the provider: the shared + // system instruction plus projected message/tool payloads. Provider protocol + // framing is intentionally excluded. + plan.PromptChars = len(agent.CompactionSummaryInstructions) + summaryResult.ProjectedChars + plan.Truncated = summaryResult.Truncated if _, err := m.sessionStore.RecordCompaction(m.activeSession.SessionID, sessions.RecordCompactionInput{ Plan: plan, - Summary: summary, + Summary: summaryResult.SummaryText, }); err != nil { return m, CompactResult{}, err } @@ -885,32 +897,47 @@ func (m model) compactActiveSession() (model, CompactResult, error) { Compacted: len(events) < len(beforeEvents)+1, BeforeTokens: beforeTokens, AfterTokens: estimateTranscriptTokens(m.transcript), - Summary: summary, + Summary: summaryResult.SummaryText, }, nil } -func (m model) summarizeCompactionPlan(plan sessions.CompactionPlan) (string, error) { - if m.provider == nil { - return deterministicCompactionSummary(plan), nil - } - stream, err := m.provider.StreamCompletion(m.ctx, zeroruntime.CompletionRequest{ - Messages: []zeroruntime.Message{ - {Role: zeroruntime.MessageRoleSystem, Content: "Summarize compacted Zero session events for future coding context. Preserve user goals, decisions, files, tool outcomes, blockers, and exact next steps. Omit secrets and do not invent details."}, - {Role: zeroruntime.MessageRoleUser, Content: plan.SummaryPrompt}, - }, - }) - if err != nil { - return "", fmt.Errorf("summarize compacted session: %w", err) - } - collected := zeroruntime.CollectStream(m.ctx, stream) - if collected.Error != "" { - return "", fmt.Errorf("summarize compacted session: %s", collected.Error) +func sessionEventsForRefs(events []sessions.Event, refs []sessions.EventRef) ([]sessions.Event, error) { + byID := make(map[string]sessions.Event, len(events)) + for _, event := range events { + byID[event.ID] = event } - summary := strings.TrimSpace(collected.Text) - if summary == "" { - return "", fmt.Errorf("summarize compacted session: empty summary") + selected := make([]sessions.Event, 0, len(refs)) + for _, ref := range refs { + event, ok := byID[ref.ID] + if !ok || event.Sequence != ref.Sequence || event.Type != ref.Type { + return nil, fmt.Errorf("session changed while compaction was being planned; retry /compact") + } + selected = append(selected, event) } - return summary, nil + return selected, nil +} + +func (m model) summarizeCompactionPlan(plan sessions.CompactionPlan, messages []zeroruntime.Message) (agent.CompactionSummaryResult, error) { + return agent.SummarizeCompactionMessages(messages, func(projected []zeroruntime.Message) (string, error) { + if m.provider == nil { + return deterministicCompactionSummary(plan), nil + } + stream, streamErr := m.provider.StreamCompletion(m.ctx, zeroruntime.CompletionRequest{ + Messages: append([]zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: agent.CompactionSummaryInstructions}}, projected...), + }) + if streamErr != nil { + return "", fmt.Errorf("summarize compacted session: %w", streamErr) + } + collected := zeroruntime.CollectStream(m.ctx, stream) + if collected.Error != "" { + return "", fmt.Errorf("summarize compacted session: %s", collected.Error) + } + result := strings.TrimSpace(collected.Text) + if result == "" { + return "", fmt.Errorf("summarize compacted session: empty summary") + } + return result, nil + }) } func deterministicCompactionSummary(plan sessions.CompactionPlan) string { diff --git a/internal/tui/session_controls_test.go b/internal/tui/session_controls_test.go index 7bfd37ebe..7e9f5b353 100644 --- a/internal/tui/session_controls_test.go +++ b/internal/tui/session_controls_test.go @@ -481,6 +481,15 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) { if err != nil { t.Fatal(err) } + for _, input := range []sessions.AppendEventInput{ + {Type: sessions.EventToolCall, Payload: map[string]any{"id": "ask", "name": "ask_user", "arguments": `{"questions":[{"question":"Which database should remain?"}]}`}}, + {Type: sessions.EventToolResult, Payload: map[string]any{"toolCallId": "ask", "name": "ask_user", "status": "ok", "output": "Postgres only"}}, + } { + m, err = m.appendSessionEvent(input.Type, input.Payload) + if err != nil { + t.Fatal(err) + } + } for _, content := range []string{ "old decision A", "old decision B", @@ -521,6 +530,15 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) { if len(provider.requests) != 1 { t.Fatalf("expected one provider summarization request, got %d", len(provider.requests)) } + request := provider.requests[0] + if len(request.Messages) != 2 || request.Messages[0].Content != agent.CompactionSummaryInstructions { + t.Fatalf("manual compaction did not use the shared agent compaction prompt: %#v", request.Messages) + } + for _, want := range []string{"Which database should remain?", "Postgres only"} { + if !strings.Contains(request.Messages[1].Content, want) { + t.Fatalf("manual compaction projection lost %q: %q", want, request.Messages[1].Content) + } + } if next.lastCompactResult == nil || next.lastCompactResult.Summary != "Provider summary keeps the actual old decisions." { t.Fatalf("expected provider summary result, got %#v", next.lastCompactResult) } @@ -533,6 +551,56 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) { } } +func TestManualCompactionPersistsStructuralTruncationMetadata(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + provider := &fakeProvider{events: []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventText, Content: "Bounded summary."}, + {Type: zeroruntime.StreamEventDone}, + }} + m := newModel(context.Background(), Options{ModelName: "gpt-4.1", Provider: provider, SessionStore: store}) + var err error + m, err = m.ensureActiveSession("compact a large session") + if err != nil { + t.Fatal(err) + } + for index := range 20 { + content := strings.Repeat("contextword ", 256) + string(rune('a'+index)) + m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{"role": "user", "content": content}) + if err != nil { + t.Fatal(err) + } + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: content}) + } + + next, result, err := m.compactActiveSession() + if err != nil || !result.Compacted { + t.Fatalf("compactActiveSession() = (%#v, %v)", result, err) + } + raw, err := store.ReadEvents(next.activeSession.SessionID) + if err != nil { + t.Fatal(err) + } + latest := raw[len(raw)-1] + if latest.Type != sessions.EventCompaction { + t.Fatalf("latest event = %s, want compaction", latest.Type) + } + payload := sessionPayload(latest) + if !payloadBool(payload, "truncated") { + t.Fatalf("compaction did not persist structural truncation: %#v", payload) + } + request := provider.requests[0] + wantPromptChars := len(agent.CompactionSummaryInstructions) + for _, message := range request.Messages[1:] { + wantPromptChars += len(message.Content) + for _, call := range message.ToolCalls { + wantPromptChars += len(call.Name) + len(call.Arguments) + } + } + if promptChars, ok := payload["promptChars"].(float64); !ok || int(promptChars) != wantPromptChars { + t.Fatalf("promptChars = %#v, want %d", payload["promptChars"], wantPromptChars) + } +} + func TestCompactCommandRecordsRequestWhenNoCompactorIsAvailable(t *testing.T) { m := newModel(context.Background(), Options{}) m.input.SetValue("/compact") diff --git a/internal/zeroruntime/types.go b/internal/zeroruntime/types.go index 514f9791f..dc76ef79a 100644 --- a/internal/zeroruntime/types.go +++ b/internal/zeroruntime/types.go @@ -93,12 +93,14 @@ type ReasoningBlock struct { // Message is a normalized conversation turn passed to providers. type Message struct { - Role MessageRole - Content string - ToolCalls []ToolCall - ToolCallID string - Images []ImageBlock // optional; nil for text-only messages - Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay + Role MessageRole + Content string + ToolCalls []ToolCall + ToolCallID string + IsError bool // tool-result status; ignored for non-tool messages + ChangedFiles []string // durable tool-result mutation targets; ignored by providers + Images []ImageBlock // optional; nil for text-only messages + Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay } // ToolDefinition describes a model-visible tool and its JSON-schema parameters.