From 70d8c8c32761c47beab92c7f00ef7b48bb1982ac Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:30:44 +0800 Subject: [PATCH 1/3] feat: summarize applied file changes for each thread reply --- docs/openapi.json | 223 +++++++++++++++++ internal/application/thread_change_diff.go | 72 ++++++ internal/application/thread_changes.go | 255 ++++++++++++++++++++ internal/application/thread_changes_test.go | 221 +++++++++++++++++ internal/httpapi/openapi.go | 7 + internal/httpapi/openapi_test.go | 6 +- internal/httpapi/thread_changes.go | 26 ++ internal/httpapi/thread_changes_test.go | 95 ++++++++ internal/httpapi/thread_control.go | 2 + internal/store/thread_changes.go | 74 ++++++ internal/store/thread_changes_test.go | 53 ++++ web/src/api/schema.d.ts | 92 +++++++ 12 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 internal/application/thread_change_diff.go create mode 100644 internal/application/thread_changes.go create mode 100644 internal/application/thread_changes_test.go create mode 100644 internal/httpapi/thread_changes.go create mode 100644 internal/httpapi/thread_changes_test.go create mode 100644 internal/store/thread_changes.go create mode 100644 internal/store/thread_changes_test.go diff --git a/docs/openapi.json b/docs/openapi.json index 7aba6c58..9547a7aa 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -28295,6 +28295,101 @@ "x-cyberagent-read-only": false } }, + "/api/v1/threads/{thread_id}/changes": { + "get": { + "operationId": "getThreadChanges", + "summary": "Read recorded file changes before one Thread reply", + "description": "Read-only net change summary for applied FileEdit records since the previous durable assistant reply in the same Run. The source event must be an assistant Session message in this Thread. Repeated edits are combined, unapplied proposals are excluded, and current workspace files are never read. Redacted or discontinuous histories omit counts and diff bodies; bounded diff omission is explicit.", + "tags": [ + "Threads" + ], + "parameters": [ + { + "name": "thread_id", + "in": "path", + "description": "Thread identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "source_event_id", + "in": "query", + "description": "Durable assistant session.message_created event ID from the Thread transcript", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ThreadChangeSummary" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/Unavailable" + }, + "504": { + "$ref": "#/components/responses/GatewayTimeout" + } + }, + "x-cyberagent-read-only": true + } + }, "/api/v1/threads/{thread_id}/command-service-policy": { "get": { "operationId": "getThreadCommandServicePolicy", @@ -68169,6 +68264,134 @@ ], "type": "object" }, + "ThreadChangeFile": { + "additionalProperties": false, + "properties": { + "additions": { + "format": "int32", + "type": "integer" + }, + "counts_available": { + "type": "boolean" + }, + "deletions": { + "format": "int32", + "type": "integer" + }, + "destination_path": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "diff_truncated": { + "type": "boolean" + }, + "edit_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "operation": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspace_id", + "path", + "operation", + "additions", + "deletions", + "counts_available", + "diff", + "diff_truncated", + "edit_ids" + ], + "type": "object" + }, + "ThreadChangeSummary": { + "additionalProperties": false, + "properties": { + "additions": { + "format": "int32", + "type": "integer" + }, + "change_scope": { + "type": "string" + }, + "counts_available": { + "type": "boolean" + }, + "deletions": { + "format": "int32", + "type": "integer" + }, + "file_count": { + "format": "int32", + "type": "integer" + }, + "files": { + "items": { + "$ref": "#/components/schemas/ThreadChangeFile" + }, + "type": "array" + }, + "partial": { + "type": "boolean" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "thread_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "version", + "thread_id", + "source_event_id", + "run_id", + "change_scope", + "files", + "file_count", + "additions", + "deletions", + "counts_available", + "partial", + "reasons" + ], + "type": "object" + }, "ThreadCommandServicePolicy": { "additionalProperties": false, "properties": { diff --git a/internal/application/thread_change_diff.go b/internal/application/thread_change_diff.go new file mode 100644 index 00000000..c9517d82 --- /dev/null +++ b/internal/application/thread_change_diff.go @@ -0,0 +1,72 @@ +package application + +import ( + "fmt" + "strings" + "time" + + gitdiff "github.com/go-git/go-git/v5/utils/diff" +) + +// threadChangeDiff retains equal lines between separate edits as context. The +// proposal diff's prefix/suffix-only algorithm would count those lines twice. +// go-git's existing line diff provides the same chunks used for these counts. +func threadChangeDiff(path, before, after string, budget time.Duration) (string, int, int, bool) { + if budget <= 0 { + return "", 0, 0, false + } + started := time.Now() + chunks := gitdiff.DoWithTimeout(before, after, min(budget, 100*time.Millisecond)) + if time.Since(started) >= min(budget, 100*time.Millisecond) { + // The dependency can return a bulk replacement on timeout; do not + // present that incomplete calculation as a precise line count. + return "", 0, 0, false + } + var body strings.Builder + start, oldCount, newCount, additions, deletions := 1, 0, 0, 0, 0 + for index, chunk := range chunks { + lines := strings.SplitAfter(chunk.Text, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + prefix := byte(' ') + switch chunk.Type { + case -1: + prefix = '-' + deletions += len(lines) + case 1: + prefix = '+' + additions += len(lines) + case 0: + if index == 0 && len(lines) > 3 { + start += len(lines) - 3 + lines = lines[len(lines)-3:] + } + if index == len(chunks)-1 && len(lines) > 3 { + lines = lines[:3] + } + } + if prefix != '+' { + oldCount += len(lines) + } + if prefix != '-' { + newCount += len(lines) + } + for _, line := range lines { + body.WriteByte(prefix) + body.WriteString(line) + if !strings.HasSuffix(line, "\n") { + body.WriteString("\n\\ No newline at end of file\n") + } + } + } + oldStart, newStart := start, start + if oldCount == 0 { + oldStart = 0 + } + if newCount == 0 { + newStart = 0 + } + return fmt.Sprintf("--- a/%s\n+++ b/%s\n@@ -%d,%d +%d,%d @@\n%s", path, path, + oldStart, oldCount, newStart, newCount, body.String()), additions, deletions, true +} diff --git a/internal/application/thread_changes.go b/internal/application/thread_changes.go new file mode 100644 index 00000000..b0a29d51 --- /dev/null +++ b/internal/application/thread_changes.go @@ -0,0 +1,255 @@ +package application + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/fileedit" + "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/session" +) + +const ( + ThreadChangeSummaryVersion = "thread_change_summary.v1" + MaxThreadChangeEdits = 500 + MaxThreadChangeDiffBytes = 512 * 1024 +) + +type ThreadChangeSummary struct { + Version string `json:"version"` + ThreadID string `json:"thread_id"` + SourceEventID string `json:"source_event_id"` + RunID string `json:"run_id"` + ChangeScope string `json:"change_scope"` + Files []ThreadChangeFile `json:"files"` + FileCount int `json:"file_count"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CountsAvailable bool `json:"counts_available"` + Partial bool `json:"partial"` + Reasons []string `json:"reasons"` +} + +type ThreadChangeFile struct { + WorkspaceID string `json:"workspace_id"` + Path string `json:"path"` + DestinationPath string `json:"destination_path,omitempty"` + Operation string `json:"operation"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CountsAvailable bool `json:"counts_available"` + Diff string `json:"diff"` + DiffTruncated bool `json:"diff_truncated"` + EditIDs []string `json:"edit_ids"` + Reason string `json:"reason,omitempty"` +} + +type ThreadChangeStore interface { + ListThreadReplyFileEditEvents(context.Context, string, string, int) (events.Event, []events.Event, error) + GetRun(context.Context, string) (domain.Run, error) + GetFileEdit(context.Context, string) (fileedit.Edit, error) + FileEditWorkspaceBelongsToRun(context.Context, string, string, string) (bool, error) +} + +type ThreadChangeService struct{ store ThreadChangeStore } + +func NewThreadChangeService(store ThreadChangeStore) *ThreadChangeService { + return &ThreadChangeService{store: store} +} + +// Summary only observes persisted FileEdit applications before one exact reply. +// It never reads the current filesystem or attributes shell/user writes to AI. +func (s *ThreadChangeService) Summary(ctx context.Context, threadID, sourceEventID string) (ThreadChangeSummary, error) { + if !validControlIdentity(threadID) || !validControlIdentity(sourceEventID) { + return ThreadChangeSummary{}, apperror.New(apperror.CodeInvalidArgument, "Thread and reply event identities are required") + } + source, facts, err := s.store.ListThreadReplyFileEditEvents(ctx, threadID, sourceEventID, MaxThreadChangeEdits+1) + if err != nil { + return ThreadChangeSummary{}, apperror.Normalize(err) + } + var reply struct { + Role string `json:"role"` + SourceKind string `json:"source_kind"` + SessionID string `json:"session_id"` + } + if source.EventID != sourceEventID || source.Type != events.SessionMessageEvent || source.Source != "session_store" || + json.Unmarshal([]byte(source.PayloadJSON), &reply) != nil || reply.Role != "assistant" || reply.SourceKind != session.SourceModelResponse { + return ThreadChangeSummary{}, threadChangeBindingError() + } + run, err := s.store.GetRun(ctx, source.RunID) + if err != nil { + return ThreadChangeSummary{}, apperror.Normalize(err) + } + if run.ID != source.RunID || run.SessionID != reply.SessionID || run.MissionID != source.MissionID { + return ThreadChangeSummary{}, threadChangeBindingError() + } + result := ThreadChangeSummary{Version: ThreadChangeSummaryVersion, ThreadID: threadID, + SourceEventID: sourceEventID, RunID: run.ID, ChangeScope: "recorded_file_edits", + Files: []ThreadChangeFile{}, CountsAvailable: true, Reasons: []string{}} + if len(facts) > MaxThreadChangeEdits { + result.CountsAvailable = false + result.markPartial("edit_limit") + return result, nil + } + // Current paths follow moves; the ordered list retains first-application order. + current := map[string]*threadChangeFileState{} + ordered := []*threadChangeFileState{} + seen := map[string]bool{} + var previousSequence int64 + for _, fact := range facts { + if fact.RunID != run.ID || fact.Type != events.FileEditAppliedEvent || fact.Source != "fileedit_store" || + fact.Sequence <= previousSequence || fact.Sequence >= source.Sequence || !validControlIdentity(fact.SubjectID) { + return ThreadChangeSummary{}, threadChangeBindingError() + } + previousSequence = fact.Sequence + if seen[fact.SubjectID] { + continue + } + seen[fact.SubjectID] = true + edit, err := s.store.GetFileEdit(ctx, fact.SubjectID) + if err != nil { + return ThreadChangeSummary{}, apperror.Normalize(err) + } + if err := s.validateEdit(ctx, run, fact, edit); err != nil { + return ThreadChangeSummary{}, err + } + key := edit.WorkspaceID + "\x00" + edit.Path + state := current[key] + if state == nil { + state = &threadChangeFileState{workspaceID: edit.WorkspaceID, path: edit.Path, currentPath: edit.Path, + originalHash: edit.OriginalHash, currentHash: edit.OriginalHash, + originalText: edit.OriginalText, currentText: edit.OriginalText, editIDs: []string{}} + current[key] = state + ordered = append(ordered, state) + } + if state.currentHash != edit.OriginalHash { + state.reason = "non_contiguous_edits" + } + if edit.SecretsRedacted || redact.String(edit.OriginalText) != edit.OriginalText || redact.String(edit.ProposedText) != edit.ProposedText { + state.reason = "secrets_redacted" + } + state.editIDs = append(state.editIDs, edit.ID) + state.currentHash, state.currentText = edit.ProposedHash, edit.ProposedText + if edit.Operation == fileedit.OperationMove { + destinationKey := edit.WorkspaceID + "\x00" + edit.DestinationPath + if destination := current[destinationKey]; destination != nil && destination != state { + // Two separately tracked files converge on one path. Do not merge + // their bodies or imply that intervening content was authored here. + destination.reason, state.reason = "non_contiguous_edits", "non_contiguous_edits" + } + delete(current, key) + current[destinationKey] = state + state.currentPath = edit.DestinationPath + state.currentHash, state.currentText = edit.DestinationProposedHash, edit.OriginalText + } + } + remaining := MaxThreadChangeDiffBytes + diffDeadline := time.Now().Add(time.Second) + for _, state := range ordered { + if err := ctx.Err(); err != nil { + return ThreadChangeSummary{}, err + } + if state.originalHash == state.currentHash && (state.path == state.currentPath || state.currentHash == "missing") { + continue + } + file := state.project(time.Until(diffDeadline)) + if file.Reason != "" { + result.CountsAvailable = false + result.markPartial(file.Reason) + } + if len(file.Diff) > remaining { + file.Diff, file.DiffTruncated = "", true + result.markPartial("diff_budget") + } else { + remaining -= len(file.Diff) + } + result.Files = append(result.Files, file) + result.Additions += file.Additions + result.Deletions += file.Deletions + } + result.FileCount = len(result.Files) + return result, nil +} + +func (s *ThreadChangeService) validateEdit(ctx context.Context, run domain.Run, fact events.Event, edit fileedit.Edit) error { + var payload struct { + SessionID string `json:"session_id"` + WorkspaceID string `json:"workspace_id"` + Path string `json:"path"` + Operation string `json:"operation"` + DestinationPath string `json:"destination_path"` + Status string `json:"status"` + } + if json.Unmarshal([]byte(fact.PayloadJSON), &payload) != nil || edit.ID != fact.SubjectID || + edit.Status != fileedit.StatusApplied || payload.Status != fileedit.StatusApplied || + payload.SessionID != edit.SessionID || payload.WorkspaceID != edit.WorkspaceID || payload.Path != edit.Path || + payload.Operation != edit.Operation || payload.DestinationPath != edit.DestinationPath || edit.Path == "" { + return threadChangeBindingError() + } + if _, err := fileedit.NormalizeOperation(edit.Operation); err != nil { + return threadChangeBindingError() + } + belongs, err := s.store.FileEditWorkspaceBelongsToRun(ctx, run.ID, edit.SessionID, edit.WorkspaceID) + if err != nil { + return apperror.Normalize(err) + } + if !belongs { + return threadChangeBindingError() + } + return nil +} + +type threadChangeFileState struct { + workspaceID, path, currentPath string + originalHash, currentHash string + originalText, currentText string + editIDs []string + reason string +} + +func (state *threadChangeFileState) project(diffBudget time.Duration) ThreadChangeFile { + file := ThreadChangeFile{WorkspaceID: state.workspaceID, Path: safeThreadActivityPath(state.path), + Operation: fileedit.OperationReplace, CountsAvailable: state.reason == "", EditIDs: state.editIDs, Reason: state.reason} + if state.originalHash == "missing" { + file.Operation, file.Path = fileedit.OperationCreate, safeThreadActivityPath(state.currentPath) + } else if state.currentHash == "missing" { + file.Operation = fileedit.OperationDelete + } else if state.path != state.currentPath { + file.Operation, file.DestinationPath = fileedit.OperationMove, safeThreadActivityPath(state.currentPath) + } + if !file.CountsAvailable { + return file + } + if file.Operation == fileedit.OperationMove { + file.Diff = fmt.Sprintf("rename from %s\nrename to %s\n", file.Path, file.DestinationPath) + } + if state.originalText != state.currentText { + diff, additions, deletions, available := threadChangeDiff(file.Path, state.originalText, state.currentText, diffBudget) + if !available { + file.CountsAvailable, file.Reason, file.Diff = false, "diff_timeout", "" + return file + } + file.Additions, file.Deletions = additions, deletions + file.Diff += diff + } + return file +} + +func (result *ThreadChangeSummary) markPartial(reason string) { + result.Partial = true + for _, previous := range result.Reasons { + if previous == reason { + return + } + } + result.Reasons = append(result.Reasons, reason) +} + +func threadChangeBindingError() error { + return apperror.New(apperror.CodeFailedPrecondition, "durable Thread change records have an inconsistent binding") +} diff --git a/internal/application/thread_changes_test.go b/internal/application/thread_changes_test.go new file mode 100644 index 00000000..76c1c0f7 --- /dev/null +++ b/internal/application/thread_changes_test.go @@ -0,0 +1,221 @@ +package application_test + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/fileedit" + "cyberagent-workbench/internal/session" + "cyberagent-workbench/internal/store" +) + +func applyThreadChange(t *testing.T, st *store.SQLiteStore, run domain.Run, root, path, operation, destination, text string) fileedit.Edit { + t.Helper() + mission, err := st.GetMission(t.Context(), run.MissionID) + if err != nil { + t.Fatal(err) + } + proposal := fileedit.Proposal{SessionID: run.SessionID, WorkspaceID: mission.WorkspaceID, + WorkspaceRoot: root, Path: path, Operation: operation, DestinationPath: destination, ProposedText: text} + if operation == fileedit.OperationCreate { + proposal.ExpectedOriginalHash = "missing" + } + if operation == fileedit.OperationMove { + proposal.ExpectedDestinationHash = "missing" + } + manager := fileedit.NewManager(st) + edit, err := manager.Propose(t.Context(), proposal) + if err != nil { + t.Fatal(err) + } + if _, err := application.NewFileEditReviewService(st).Review(t.Context(), application.ReviewFileEditRequest{ + Version: application.FileEditReviewProtocolVersion, RunID: run.ID, EditID: edit.ID, Action: application.FileEditApproveIntent}); err != nil { + t.Fatal(err) + } + edit, err = manager.Approve(t.Context(), edit.ID, root) + if err != nil { + t.Fatal(err) + } + return edit +} + +func saveThreadChangeReply(t *testing.T, st *store.SQLiteStore, run domain.Run, text string) events.Event { + t.Helper() + message, err := st.SaveSessionMessage(t.Context(), session.NewMessage(run.SessionID, "assistant", text)) + if err != nil { + t.Fatal(err) + } + list, err := st.ListRunEvents(t.Context(), run.ID) + if err != nil { + t.Fatal(err) + } + for _, event := range list { + if event.Type == events.SessionMessageEvent && event.SubjectID == fmt.Sprint(message.ID) { + return event + } + } + t.Fatal("reply event was not projected") + return events.Event{} +} + +func TestThreadChangesUseReplyIntervalsAndNetAppliedContent(t *testing.T) { + root := t.TempDir() + st, run := threadReviewFixture(t, root) + if err := os.WriteFile(filepath.Join(root, "app.txt"), []byte("before\n"), 0600); err != nil { + t.Fatal(err) + } + first := applyThreadChange(t, st, run, root, "app.txt", "replace", "", "intermediate\n") + last := applyThreadChange(t, st, run, root, "app.txt", "replace", "", "after\n") + applyThreadChange(t, st, run, root, "temporary.txt", "create", "", "temporary\n") + applyThreadChange(t, st, run, root, "temporary.txt", "delete", "", "") + _, err := fileedit.NewManager(st).Propose(t.Context(), fileedit.Proposal{SessionID: run.SessionID, + WorkspaceID: "review-workspace", WorkspaceRoot: root, Path: "pending.txt", ProposedText: "unapplied\n"}) + if err != nil { + t.Fatal(err) + } + // Real operator input is committed after the tools; it must not discard them. + if _, err := st.SaveSessionMessage(t.Context(), session.NewMessage(run.SessionID, "user", "make the change")); err != nil { + t.Fatal(err) + } + reply := saveThreadChangeReply(t, st, run, "first answer") + service := application.NewThreadChangeService(st) + result, err := service.Summary(t.Context(), domain.InitialThreadID(run.ID), reply.EventID) + if err != nil { + t.Fatal(err) + } + if result.Partial || !result.CountsAvailable || result.FileCount != 1 || result.Additions != 1 || result.Deletions != 1 || + !reflect.DeepEqual(result.Files[0].EditIDs, []string{first.ID, last.ID}) || + strings.Contains(result.Files[0].Diff, "intermediate") || !strings.Contains(result.Files[0].Diff, "-before\n+after\n") { + t.Fatalf("wrong applied net changes: %+v", result) + } + applyThreadChange(t, st, run, root, "app.txt", "replace", "", "third\n") + secondReply := saveThreadChangeReply(t, st, run, "second answer") + second, err := service.Summary(t.Context(), domain.InitialThreadID(run.ID), secondReply.EventID) + if err != nil || second.FileCount != 1 || second.Additions != 1 || second.Deletions != 1 || + !strings.Contains(second.Files[0].Diff, "-after\n+third\n") { + t.Fatalf("same-Run turns mixed: %+v %v", second, err) + } + // Current workspace content has no bearing on a completed reply's history. + if err := os.WriteFile(filepath.Join(root, "app.txt"), []byte("external later change\n"), 0600); err != nil { + t.Fatal(err) + } + before, err := st.ExportThread(t.Context(), domain.InitialThreadID(run.ID)) + if err != nil { + t.Fatal(err) + } + again, err := service.Summary(t.Context(), domain.InitialThreadID(run.ID), reply.EventID) + if err != nil || !reflect.DeepEqual(again, result) { + t.Fatalf("historical summary drifted with current state: %+v %v", again, err) + } + after, err := st.ExportThread(t.Context(), domain.InitialThreadID(run.ID)) + if err != nil { + t.Fatal(err) + } + before.ExportedAt, after.ExportedAt = time.Time{}, time.Time{} + if !reflect.DeepEqual(before, after) { + t.Fatal("reading changes mutated Thread history") + } +} + +func TestThreadChangesHandleMovesDeletesRedactionAndExternalGaps(t *testing.T) { + root := t.TempDir() + st, run := threadReviewFixture(t, root) + for path, text := range map[string]string{"old.txt": "old\n", "deleted.txt": "deleted\n", "gap.txt": "base\n", + "secret.txt": "token=sk-" + strings.Repeat("q", 35) + "\n"} { + if err := os.WriteFile(filepath.Join(root, path), []byte(text), 0600); err != nil { + t.Fatal(err) + } + } + applyThreadChange(t, st, run, root, "old.txt", "move", "new.txt", "") + applyThreadChange(t, st, run, root, "new.txt", "replace", "", "new\n") + applyThreadChange(t, st, run, root, "deleted.txt", "delete", "", "") + applyThreadChange(t, st, run, root, "secret.txt", "replace", "", "safe\n") + applyThreadChange(t, st, run, root, "gap.txt", "replace", "", "agent version\n") + if err := os.WriteFile(filepath.Join(root, "gap.txt"), []byte("external private content\n"), 0600); err != nil { + t.Fatal(err) + } + applyThreadChange(t, st, run, root, "gap.txt", "replace", "", "final version\n") + reply := saveThreadChangeReply(t, st, run, "done") + result, err := application.NewThreadChangeService(st).Summary(t.Context(), domain.InitialThreadID(run.ID), reply.EventID) + if err != nil { + t.Fatal(err) + } + if !result.Partial || result.CountsAvailable || result.FileCount != 4 || result.Additions != 1 || result.Deletions != 2 { + t.Fatalf("wrong mixed summary: %+v", result) + } + files := map[string]application.ThreadChangeFile{} + for _, file := range result.Files { + files[file.Path] = file + } + if files["old.txt"].Operation != "move" || files["old.txt"].DestinationPath != "new.txt" || + files["old.txt"].Additions != 1 || files["old.txt"].Deletions != 1 || files["deleted.txt"].Operation != "delete" { + t.Fatalf("move/delete not preserved: %+v", files) + } + for path, reason := range map[string]string{"secret.txt": "secrets_redacted", "gap.txt": "non_contiguous_edits"} { + file := files[path] + if file.CountsAvailable || file.Diff != "" || file.Reason != reason || file.Additions != 0 || file.Deletions != 0 { + t.Fatalf("unsafe content was attributed: %+v", file) + } + } +} + +func TestThreadChangesKeepRunHistorySeparate(t *testing.T) { + st, first, registry := newThreadModelSettingsFixture(t) + workspace, err := st.GetWorkspaceInfo(t.Context(), "workspace-model-settings") + if err != nil { + t.Fatal(err) + } + applyThreadChange(t, st, first, workspace.RootPath, "run.txt", "create", "", "first\n") + firstReply := saveThreadChangeReply(t, st, first, "first reply") + second := executeThreadModelSettingsSwitch(t, st, first, registry).Submission.Run + applyThreadChange(t, st, second, workspace.RootPath, "run.txt", "replace", "", "second\n") + secondReply := saveThreadChangeReply(t, st, second, "second reply") + service := application.NewThreadChangeService(st) + threadID := domain.InitialThreadID(first.ID) + old, err := service.Summary(t.Context(), threadID, firstReply.EventID) + if err != nil || old.RunID != first.ID || old.FileCount != 1 || old.Additions != 1 || old.Deletions != 0 { + t.Fatalf("old Run was lost: %+v %v", old, err) + } + current, err := service.Summary(t.Context(), threadID, secondReply.EventID) + if err != nil || current.RunID != second.ID || current.FileCount != 1 || current.Additions != 1 || current.Deletions != 1 { + t.Fatalf("Run histories mixed: %+v %v", current, err) + } +} + +func TestThreadChangesBoundDiffBytesWithoutChangingCounts(t *testing.T) { + root := t.TempDir() + st, run := threadReviewFixture(t, root) + for _, path := range []string{"one.txt", "two.txt"} { + applyThreadChange(t, st, run, root, path, "create", "", strings.Repeat("line\n", 50000)) + } + reply := saveThreadChangeReply(t, st, run, "done") + result, err := application.NewThreadChangeService(st).Summary(t.Context(), domain.InitialThreadID(run.ID), reply.EventID) + if err != nil || !result.Partial || !result.CountsAvailable || result.FileCount != 2 || result.Additions != 100000 || + len(result.Files[0].Diff)+len(result.Files[1].Diff) > application.MaxThreadChangeDiffBytes || + result.Files[0].DiffTruncated || !result.Files[1].DiffTruncated || result.Files[1].Diff != "" { + t.Fatalf("wrong bounded diff result: files=%d additions=%d partial=%v counts=%v err=%v", result.FileCount, result.Additions, result.Partial, result.CountsAvailable, err) + } +} + +func TestThreadChangesCountSeparatedEditsWithoutUnchangedLines(t *testing.T) { + root := t.TempDir() + st, run := threadReviewFixture(t, root) + if err := os.WriteFile(filepath.Join(root, "separate.txt"), []byte("old first\nkeep middle\nold last\n"), 0600); err != nil { + t.Fatal(err) + } + applyThreadChange(t, st, run, root, "separate.txt", "replace", "", "new first\nkeep middle\nnew last\n") + reply := saveThreadChangeReply(t, st, run, "done") + result, err := application.NewThreadChangeService(st).Summary(t.Context(), domain.InitialThreadID(run.ID), reply.EventID) + if err != nil || result.Additions != 2 || result.Deletions != 2 || result.Partial || + !strings.Contains(result.Files[0].Diff, " keep middle\n") { + t.Fatalf("unchanged middle lines were counted as edited: %+v %v", result, err) + } +} diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 95118a5c..3684167d 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -1078,6 +1078,13 @@ func openAPIOperationSpecs() []openAPIOperationSpec { DataType: reflect.TypeOf(ThreadTranscriptItemView{}), Collection: true, Paginated: true, NotFound: true, Parameters: append([]openAPIParameter{threadID}, threadTranscriptPaginationParameters()...)}, + {Path: ThreadChangesPathTemplate, OperationID: "getThreadChanges", + Summary: "Read recorded file changes before one Thread reply", Tag: "Threads", + Description: "Read-only net change summary for applied FileEdit records since the previous durable assistant reply in the same Run. The source event must be an assistant Session message in this Thread. Repeated edits are combined, unapplied proposals are excluded, and current workspace files are never read. Redacted or discontinuous histories omit counts and diff bodies; bounded diff omission is explicit.", + DataType: reflect.TypeOf(application.ThreadChangeSummary{}), NotFound: true, + Parameters: []openAPIParameter{threadID, {Name: "source_event_id", In: "query", Required: true, + Description: "Durable assistant session.message_created event ID from the Thread transcript", + Schema: map[string]any{"type": "string", "minLength": 1, "maxLength": 256}}}}, {Path: ThreadQueuedMessagesPathTemplate, OperationID: "listThreadQueuedMessages", Summary: "Read every durable queued message for the current Thread Run", Tag: "Threads", Description: "Reads the complete bounded pending queue and prepared flags in one transaction. Counts exclude committed and cancelled messages. It does not start or resume execution.", diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go index ddc45659..e0ef9c13 100644 --- a/internal/httpapi/openapi_test.go +++ b/internal/httpapi/openapi_test.go @@ -1304,14 +1304,16 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { } else if spec.Path == FileEditProposalRecoveryPathTemplate { requestPath = strings.ReplaceAll(spec.Path, "{run_id}", fixture.run.ID) requestPath = strings.ReplaceAll(requestPath, "{edit_id}", recoveryProposal.Edit.ID) - } else if spec.OperationID == "branchThread" { + } else if spec.OperationID == "branchThread" || spec.OperationID == "getThreadChanges" { requestPath = strings.ReplaceAll(spec.Path, "{thread_id}", domain.InitialThreadID(branchSourceRun.ID)) } else if spec.OperationID == "deleteThread" { requestPath = strings.ReplaceAll(spec.Path, "{thread_id}", deleteThread.ID) } else if spec.OperationID == "rejectContextMemoryCandidate" { requestPath = strings.ReplaceAll(spec.Path, "{candidate_id}", openAPIRejectCandidate.ID) } - if spec.OperationID == "listContextMemoryCandidates" || spec.OperationID == "getContextMemoryCandidate" { + if spec.OperationID == "getThreadChanges" { + requestPath += "?source_event_id=" + branchSourceEventID + } else if spec.OperationID == "listContextMemoryCandidates" || spec.OperationID == "getContextMemoryCandidate" { requestPath += "?workspace_id=" + fixture.workspace.ID } else if spec.OperationID == "searchWorkspace" { requestPath += "?query=README" diff --git a/internal/httpapi/thread_changes.go b/internal/httpapi/thread_changes.go new file mode 100644 index 00000000..94fbbf7a --- /dev/null +++ b/internal/httpapi/thread_changes.go @@ -0,0 +1,26 @@ +package httpapi + +import ( + "net/http" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" +) + +const ThreadChangesPathTemplate = "/api/v1/threads/{thread_id}/changes" + +func (a *API) threadChanges(request *http.Request, threadID string) (any, *Page, error) { + query := request.URL.Query() + if err := validateSingleQueryValues(query, "source_event_id"); err != nil { + return nil, nil, err + } + if len(query["source_event_id"]) != 1 { + return nil, nil, apperror.New(apperror.CodeInvalidArgument, "source_event_id must appear exactly once") + } + reader, ok := a.store.(application.ThreadChangeStore) + if !ok { + return nil, nil, apperror.New(apperror.CodeUnavailable, "Thread change summary is unavailable") + } + result, err := application.NewThreadChangeService(reader).Summary(request.Context(), threadID, query.Get("source_event_id")) + return result, nil, err +} diff --git a/internal/httpapi/thread_changes_test.go b/internal/httpapi/thread_changes_test.go new file mode 100644 index 00000000..d16febeb --- /dev/null +++ b/internal/httpapi/thread_changes_test.go @@ -0,0 +1,95 @@ +package httpapi + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "testing" + "time" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/fileedit" + "cyberagent-workbench/internal/session" +) + +func TestThreadChangesHTTPIsReadOnlyAndThreadReplyScoped(t *testing.T) { + fixture := newAPIFixture(t) + ctx := t.Context() + manager := fileedit.NewManager(fixture.store) + edit, err := manager.Propose(ctx, fileedit.Proposal{SessionID: fixture.run.SessionID, WorkspaceID: fixture.workspace.ID, + WorkspaceRoot: fixture.workspace.RootPath, Path: "changed.txt", Operation: fileedit.OperationCreate, + ExpectedOriginalHash: "missing", ProposedText: "recorded change\n"}) + if err != nil { + t.Fatal(err) + } + if _, err := application.NewFileEditReviewService(fixture.store).Review(ctx, application.ReviewFileEditRequest{ + Version: application.FileEditReviewProtocolVersion, RunID: fixture.run.ID, EditID: edit.ID, Action: application.FileEditApproveIntent}); err != nil { + t.Fatal(err) + } + if _, err := manager.Approve(ctx, edit.ID, fixture.workspace.RootPath); err != nil { + t.Fatal(err) + } + reply, err := fixture.store.SaveSessionMessage(ctx, session.NewMessage(fixture.run.SessionID, "assistant", "change completed")) + if err != nil { + t.Fatal(err) + } + list, err := fixture.store.ListRunEvents(ctx, fixture.run.ID) + if err != nil { + t.Fatal(err) + } + var sourceID string + for _, event := range list { + if event.Type == events.SessionMessageEvent && event.SubjectID == fmt.Sprint(reply.ID) { + sourceID = event.EventID + } + } + threadID := domain.InitialThreadID(fixture.run.ID) + path := ThreadCollectionPath + "/" + threadID + "/changes" + before, err := fixture.store.ExportThread(ctx, threadID) + if err != nil { + t.Fatal(err) + } + response := performSessionMessageRequest(t, fixture.api, http.MethodGet, path+"?source_event_id="+url.QueryEscape(sourceID), testAccessToken, "", "", nil) + var result application.ThreadChangeSummary + decodeDataStatus(t, response, http.StatusOK, &result) + if result.Version != application.ThreadChangeSummaryVersion || result.ThreadID != threadID || result.SourceEventID != sourceID || + result.RunID != fixture.run.ID || result.FileCount != 1 || result.Additions != 1 || result.Deletions != 0 || + !result.CountsAvailable || result.Partial || result.Files[0].EditIDs[0] != edit.ID { + t.Fatalf("wrong reply summary: %+v", result) + } + for _, test := range []struct { + method, requestPath, token string + status int + }{ + {http.MethodGet, path, testAccessToken, http.StatusBadRequest}, + {http.MethodGet, path + "?source_event_id=" + sourceID + "&unknown=true", testAccessToken, http.StatusBadRequest}, + {http.MethodGet, path + "?source_event_id=" + sourceID + "&source_event_id=" + sourceID, testAccessToken, http.StatusBadRequest}, + {http.MethodGet, path + "?source_event_id=unknown-event", testAccessToken, http.StatusNotFound}, + {http.MethodGet, ThreadCollectionPath + "/other-thread/changes?source_event_id=" + sourceID, testAccessToken, http.StatusNotFound}, + {http.MethodGet, path + "?source_event_id=" + sourceID, "", http.StatusUnauthorized}, + {http.MethodPost, path + "?source_event_id=" + sourceID, testAccessToken, http.StatusMethodNotAllowed}, + } { + response := performSessionMessageRequest(t, fixture.api, test.method, test.requestPath, test.token, "", "", nil) + if response.Code != test.status { + t.Fatalf("%s %s expected %d got %d: %s", test.method, test.requestPath, test.status, response.Code, response.Body.String()) + } + } + // A file-edit event is not a reply source, even though it belongs to this task. + for _, event := range list { + if event.Type == events.FileEditAppliedEvent { + response := performSessionMessageRequest(t, fixture.api, http.MethodGet, path+"?source_event_id="+event.EventID, testAccessToken, "", "", nil) + assertAPIError(t, response, http.StatusNotFound, "NOT_FOUND") + } + } + after, err := fixture.store.ExportThread(ctx, threadID) + if err != nil { + t.Fatal(err) + } + before.ExportedAt, after.ExportedAt = time.Time{}, time.Time{} + if !reflect.DeepEqual(before, after) { + t.Fatal("GET changes changed durable history") + } +} diff --git a/internal/httpapi/thread_control.go b/internal/httpapi/thread_control.go index 60ea8c12..16ab2d9b 100644 --- a/internal/httpapi/thread_control.go +++ b/internal/httpapi/thread_control.go @@ -128,6 +128,8 @@ func (a *API) routeThreads(request *http.Request, segments []string) (any, *Page return a.threadPlanObservation(request, segments[1]) case "review": return a.threadReviewView(request, segments[1]) + case "changes": + return a.threadChanges(request, segments[1]) case "turn-request": return a.threadRequestObservation(request, segments[1]) case "messages": diff --git a/internal/store/thread_changes.go b/internal/store/thread_changes.go new file mode 100644 index 00000000..fc6447f2 --- /dev/null +++ b/internal/store/thread_changes.go @@ -0,0 +1,74 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "strings" + + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/session" +) + +// ListThreadReplyFileEditEvents reads the exact durable reply and its applied +// FileEdit facts in one snapshot. User Session messages are committed after +// tools, so the previous assistant reply, rather than a user-message timestamp, +// is the start of this interval. Run ownership also bounds the first reply. +func (s *SQLiteStore) ListThreadReplyFileEditEvents(ctx context.Context, + threadID, sourceEventID string, limit int, +) (events.Event, []events.Event, error) { + if limit <= 0 || limit > 1000 { + return events.Event{}, nil, errors.New("Thread reply change limit must be between 1 and 1000") + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return events.Event{}, nil, err + } + defer func() { _ = tx.Rollback() }() + source, err := scanRunEvent(tx.QueryRowContext(ctx, `SELECT e.id,e.event_id,e.version, + e.run_id,e.mission_id,e.sequence,e.type,e.source,e.subject_id,e.payload_json,e.created_at + FROM run_events e JOIN thread_runs b ON b.run_id=e.run_id + WHERE b.thread_id=? AND e.event_id=? AND e.type=? AND e.source='session_store' + AND json_extract(e.payload_json,'$.session_id')=b.session_id + AND json_extract(e.payload_json,'$.role')='assistant' + AND json_extract(e.payload_json,'$.source_kind')=?`, strings.TrimSpace(threadID), + strings.TrimSpace(sourceEventID), events.SessionMessageEvent, session.SourceModelResponse)) + if err != nil { + return events.Event{}, nil, err + } + var after int64 + err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(sequence),0) FROM run_events + WHERE run_id=? AND sequence? AND sequence Date: Mon, 19 Oct 2026 14:50:05 +0800 Subject: [PATCH 2/3] feat: show per-reply file changes and diffs in chat --- docs/convergence/protocol-registry.md | 5 +- protocols/registry.json | 1 + web/src/api/client.ts | 12 ++ web/src/api/thread-changes.ts | 4 + web/src/v2/components/narrative.tsx | 3 + web/src/v2/components/turn-changes.css | 59 +++++++++ web/src/v2/components/turn-changes.test.tsx | 85 +++++++++++++ web/src/v2/components/turn-changes.tsx | 134 ++++++++++++++++++++ web/src/v2/projection/narrative.test.ts | 1 + web/src/v2/projection/narrative.ts | 3 + 10 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 web/src/api/thread-changes.ts create mode 100644 web/src/v2/components/turn-changes.css create mode 100644 web/src/v2/components/turn-changes.test.tsx create mode 100644 web/src/v2/components/turn-changes.tsx diff --git a/docs/convergence/protocol-registry.md b/docs/convergence/protocol-registry.md index 45c7aa56..9cea1249 100644 --- a/docs/convergence/protocol-registry.md +++ b/docs/convergence/protocol-registry.md @@ -26,7 +26,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi | [exported-evidence-and-handoff](#exported-evidence-and-handoff) | `external-durable` | Evidence, verification, report, and handoff maintainers | 35 | true | | [extension-package-contracts](#extension-package-contracts) | `external-durable` | Skill, Plugin, Hook, and extension maintainers | 48 | true | | [github-pr-followup-observation](#github-pr-followup-observation) | `projection` | GitHub review and task follow-up maintainers | 1 | false | -| [http-openapi-contract](#http-openapi-contract) | `external-durable` | HTTP/OpenAPI and generated-client maintainers | 137 | true | +| [http-openapi-contract](#http-openapi-contract) | `external-durable` | HTTP/OpenAPI and generated-client maintainers | 138 | true | | [in-memory-token-session](#in-memory-token-session) | `ephemeral` | Credential and bootstrap maintainers | 1 | false | | [lsp-process-session](#lsp-process-session) | `ephemeral` | Code intelligence maintainers | 1 | false | | [mcp-interchange](#mcp-interchange) | `external-durable` | MCP client/server maintainers | 9 | true | @@ -986,7 +986,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `pr-followup-http-request-reader` (`v1`, active) at `internal/application/pr_followup.go` - `pr-followup-http-response-reader` (`v1`, active) at `web/src/api/pr-followup.ts` -
137 active identifiers +
138 active identifiers - `agent-code-tools.v1` - `agent_browser_close.v1` @@ -1101,6 +1101,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `thread_activity_summary.v1` - `thread_application_services.v1` - `thread_branch.v1` +- `thread_change_summary.v1` - `thread_command_service_policy.v1` - `thread_context_compaction.v1` - `thread_context_compaction_operations.v1` diff --git a/protocols/registry.json b/protocols/registry.json index 6e338b6d..31fa9200 100644 --- a/protocols/registry.json +++ b/protocols/registry.json @@ -1794,6 +1794,7 @@ "thread_activity_summary.v1", "thread_application_services.v1", "thread_branch.v1", + "thread_change_summary.v1", "thread_command_service_policy.v1", "thread_context_compaction.v1", "thread_context_compaction_operations.v1", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f66c5f7a..5d0cd05b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,5 @@ import { WorkflowClient } from "./workflows"; +import type { ThreadChangeSummary } from "./thread-changes"; import { creationBudgetMatches, normalizedTaskBudget, parseTaskConfiguration } from "./task-configuration"; import { parseTaskSkillCandidates, type TaskSkillCandidateScope } from "./task-skill-candidates"; import { validSkillSelectionRef } from "./project-skills"; @@ -8987,6 +8988,17 @@ export class APIClient { `/threads/${encodeURIComponent(threadID)}/execution`, {}, signal), threadID); } + async threadChanges(threadID: string, runID: string, sourceEventID: string, + signal?: AbortSignal): Promise { + const value = await this.get(`/threads/${encodeURIComponent(threadID)}/changes`, + { source_event_id: sourceEventID }, signal); + if (value.version !== "thread_change_summary.v1" || value.thread_id !== threadID || + value.run_id !== runID || value.source_event_id !== sourceEventID) { + throw new Error("The change summary does not match this reply."); + } + return value; + } + async inspectThreadCreationRequest(workspaceID: string, operationKey: string, signal?: AbortSignal): Promise { if (!boundedIdentity(workspaceID) || workspaceID.trim() !== workspaceID) { diff --git a/web/src/api/thread-changes.ts b/web/src/api/thread-changes.ts new file mode 100644 index 00000000..cc1834c3 --- /dev/null +++ b/web/src/api/thread-changes.ts @@ -0,0 +1,4 @@ +import type { components } from "./schema"; + +export type ThreadChangeSummary = components["schemas"]["ThreadChangeSummary"]; +export type ThreadChangeFile = components["schemas"]["ThreadChangeFile"]; diff --git a/web/src/v2/components/narrative.tsx b/web/src/v2/components/narrative.tsx index 90184d18..13c35b3f 100644 --- a/web/src/v2/components/narrative.tsx +++ b/web/src/v2/components/narrative.tsx @@ -11,6 +11,7 @@ import { V2ImagePreview } from "./image-input"; import { V2FileAttachments } from "./file-input"; import { V2ActivityGroup } from "./activity-detail"; import { rehypeStreamReveal } from "./stream-reveal"; +import { V2TurnChanges } from "./turn-changes"; export interface FileLinkTarget { path: string; @@ -247,6 +248,8 @@ const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpe if (entry.kind === "assistant") return
  • + {!entry.provisional && entry.changeSourceID && entry.runId && typeof client.threadChanges === "function" && + } {!entry.provisional && }
  • ; if (entry.kind === "activity") return
  • diff --git a/web/src/v2/components/turn-changes.css b/web/src/v2/components/turn-changes.css new file mode 100644 index 00000000..ae53bf9f --- /dev/null +++ b/web/src/v2/components/turn-changes.css @@ -0,0 +1,59 @@ +.v2-turn-changes { margin-top: 16px; border: 1px solid var(--v2-border); border-radius: 12px; overflow: hidden; background: var(--v2-main-surface); font-size: 13px; line-height: 1.5; } +.v2-turn-changes button, .v2-change-dialog button, .v2-change-load-error button { color: inherit; font: inherit; cursor: pointer; } +.v2-turn-changes button:focus-visible, .v2-change-dialog button:focus-visible, .v2-change-diff:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: -3px; } +.v2-turn-changes-heading { display: flex; align-items: center; gap: 12px; padding: 15px 16px; background: color-mix(in srgb, var(--v2-control) 48%, transparent); } +.v2-turn-changes-icon { flex: 0 0 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--v2-control); color: var(--v2-muted); } +.v2-turn-changes-heading > div { min-width: 0; flex: 1; } +.v2-assistant-turn .v2-turn-changes h3 { margin: 0 0 3px; font-size: 14px; font-weight: 600; line-height: 1.5; } +.v2-change-counts { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 400; } +.v2-change-added { color: var(--prayu-diff-add); } +.v2-change-removed { color: var(--prayu-diff-delete); } +.v2-change-unavailable { color: var(--v2-muted); font-size: 12px; white-space: nowrap; } +.v2-turn-changes-review { border: 1px solid var(--v2-border); border-radius: 8px; padding: 5px 10px; background: transparent; flex-shrink: 0; } +.v2-turn-changes button:hover, .v2-change-dialog button:hover { background: var(--v2-control-hover); } +.v2-assistant-turn .v2-turn-changes-files { list-style: none; padding: 6px 0; margin: 0; border-top: 1px solid var(--v2-border); } +.v2-assistant-turn .v2-turn-changes-files li { margin: 0; } +.v2-turn-changes-files button { width: 100%; min-height: 35px; display: flex; align-items: center; gap: 16px; text-align: left; background: transparent; border: 0; padding: 7px 16px; } +.v2-turn-changes-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-turn-changes-expand { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 16px; border: 0; border-top: 1px solid var(--v2-border); background: transparent; text-align: left; } +.v2-assistant-turn .v2-turn-changes-scope { margin: 0; padding: 5px 16px 8px; color: var(--v2-muted); font-size: 12px; line-height: 1.5; } +.v2-change-load-error { display: flex; align-items: center; gap: 8px; color: var(--v2-muted); font-size: 12px; margin-top: 12px; } +.v2-change-load-error button { border: 0; background: transparent; text-decoration: underline; } +.v2-change-overlay { position: fixed; inset: 0; z-index: 100; background: rgb(0 0 0 / 36%); display: grid; place-items: center; padding: 24px; } +.v2-change-dialog { width: min(1040px, 100%); height: min(720px, 88dvh); display: flex; flex-direction: column; color: var(--v2-ink); background: var(--prayu-surface-solid); border: 1px solid var(--v2-border-strong); border-radius: 14px; box-shadow: 0 16px 60px rgb(0 0 0 / 24%); font: 13px/1.6 var(--prayu-font-ui); overflow: hidden; } +.v2-change-dialog-heading { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--v2-border); } +.v2-change-dialog-heading h2 { font: inherit; font-size: 15px; font-weight: 600; margin: 0; flex: 1; } +.v2-change-dialog-heading > button { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 6px; background: transparent; } +.v2-change-dialog-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(180px, 26%) minmax(0, 1fr); } +.v2-change-file-nav { overflow-y: auto; padding: 8px; border-right: 1px solid var(--v2-border); } +.v2-change-file-nav button { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; text-align: left; padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; } +.v2-change-file-nav button > span:first-child { overflow-wrap: anywhere; } +.v2-change-file-nav button[aria-current="true"] { background: var(--v2-control-hover); } +.v2-change-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.v2-change-detail > header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--v2-border); } +.v2-change-detail > header strong { flex: 1; min-width: 0; overflow-wrap: anywhere; font-weight: 500; } +.v2-change-note { margin: 12px 16px; color: var(--v2-muted); } +.v2-change-diff { overflow: auto; flex: 1; min-height: 0; padding: 8px 0; font: 13px/1.7 var(--v2-mono); } +.v2-change-diff-line { display: flex; width: max-content; min-width: 100%; } +.v2-change-diff-line > span { flex: 0 0 44px; text-align: right; padding-right: 12px; color: var(--v2-muted); user-select: none; } +.v2-change-diff-line code { white-space: pre; font: inherit; padding-right: 16px; } +.v2-change-diff-line.is-add { background: color-mix(in srgb, var(--prayu-diff-add) 12%, transparent); } +.v2-change-diff-line.is-delete { background: color-mix(in srgb, var(--prayu-diff-delete) 12%, transparent); } +.v2-change-diff-line.is-hunk, .v2-change-diff-line.is-meta { color: var(--v2-muted); } +.v2-change-more-lines { margin: 12px 16px; padding: 6px 10px; border: 1px solid var(--v2-border); border-radius: 6px; background: var(--v2-control); } +.v2-change-dialog > footer { padding: 9px 18px; color: var(--v2-muted); font-size: 12px; border-top: 1px solid var(--v2-border); } +@media (max-width: 600px) { + .v2-turn-changes-heading { gap: 9px; padding: 12px; } + .v2-turn-changes-icon { display: none; } + .v2-turn-changes-files button { gap: 10px; padding: 8px 12px; } + .v2-turn-changes-expand { padding-inline: 12px; } + .v2-change-overlay { padding: 10px; } + .v2-change-dialog { height: 92dvh; } + .v2-change-dialog-heading { padding: 10px 12px; gap: 7px; } + .v2-change-dialog-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + .v2-change-file-nav { max-height: 140px; border-right: 0; border-bottom: 1px solid var(--v2-border); } + .v2-change-file-nav button { flex-direction: row; align-items: center; gap: 10px; } + .v2-change-file-nav button > span:first-child { flex: 1; min-width: 0; } + .v2-change-dialog > footer { padding: 8px 12px; } + .v2-change-diff-line > span { flex-basis: 32px; padding-right: 8px; } +} diff --git a/web/src/v2/components/turn-changes.test.tsx b/web/src/v2/components/turn-changes.test.tsx new file mode 100644 index 00000000..f2f1c83c --- /dev/null +++ b/web/src/v2/components/turn-changes.test.tsx @@ -0,0 +1,85 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { APIClient } from "../../api/client"; +import type { ThreadChangeSummary } from "../../api/thread-changes"; +import { V2Narrative } from "./narrative"; + +afterEach(() => cleanup()); + +function changes(overrides: Partial = {}): ThreadChangeSummary { + return { version: "thread_change_summary.v1", thread_id: "thread-1", run_id: "run-1", source_event_id: "first-reply", + change_scope: "recorded_file_edits", file_count: 5, additions: 5, deletions: 5, counts_available: true, partial: false, reasons: [], + files: ["web/src/chat.tsx", "web/src/theme.css", "README.md", "long/path/with-many-directories/messages.ts", "tests/chat.test.ts"].map((path, index) => ({ + workspace_id: "workspace-1", path, operation: "replace", additions: 1, deletions: 1, counts_available: true, + diff: `--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old ${index}\n+new ${index}\n`, diff_truncated: false, edit_ids: [`edit-${index}`], + })), ...overrides }; +} + +function show(get: ReturnType) { + const client = { get, threadChanges: APIClient.prototype.threadChanges } as unknown as APIClient; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + ); +} + +it("shows three files, expands the rest and opens the selected net diff with keyboard focus restored", async () => { + const user = userEvent.setup(); + const get = vi.fn().mockResolvedValue(changes()); + show(get); + const summary = await screen.findByRole("region", { name: "Changes in this turn" }); + expect(get).toHaveBeenCalledWith("/threads/thread-1/changes", { source_event_id: "first-reply" }, expect.any(AbortSignal)); + expect(within(summary).getAllByRole("listitem")).toHaveLength(3); + expect(within(summary).getByText("5 files edited")).toBeInTheDocument(); + await user.click(within(summary).getByRole("button", { name: "Show 2 more files" })); + expect(within(summary).getAllByRole("listitem")).toHaveLength(5); + const file = within(summary).getByRole("button", { name: /tests\/chat.test.ts/u }); + await user.click(file); + const dialog = screen.getByRole("dialog", { name: "Changes in this turn" }); + expect(within(dialog).getByText("+new 4")).toBeInTheDocument(); + expect(within(dialog).queryByText("+new 0")).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: /web\/src\/chat.tsx/u })); + expect(within(dialog).getByText("+new 0")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(file).toHaveFocus(); + await user.click(within(summary).getByRole("button", { name: "Show fewer files" })); + expect(within(summary).getAllByRole("listitem")).toHaveLength(3); +}); + +it("hides the card for a reply with no recorded net changes", async () => { + const get = vi.fn().mockResolvedValue(changes({ files: [], file_count: 0, additions: 0, deletions: 0 })); + const view = show(get); + await waitFor(() => expect(view.container.querySelector(".v2-turn-changes-slot")).toBeNull()); + expect(screen.queryByRole("region", { name: "Changes in this turn" })).not.toBeInTheDocument(); + expect(get).toHaveBeenCalledOnce(); +}); + +it("keeps unavailable counts distinct from zero and explains hidden sensitive diffs", async () => { + const user = userEvent.setup(); + const summary = changes(); + summary.files[0] = { ...summary.files[0], counts_available: false, diff: "", reason: "secrets_redacted" }; + summary.counts_available = false; + summary.partial = true; + show(vi.fn().mockResolvedValue(summary)); + const card = await screen.findByRole("region", { name: "Changes in this turn" }); + expect(within(card).getAllByText("Counts unavailable")).toHaveLength(2); + expect(within(card).getByText("Some change details are unavailable")).toBeInTheDocument(); + await user.click(within(card).getByRole("button", { name: "View changes" })); + expect(screen.getByText("This file contains sensitive content. Its diff and line counts are hidden.")).toBeInTheDocument(); + expect(screen.queryByLabelText("Code diff")).not.toBeInTheDocument(); +}); + +it("rejects another reply's summary and retries without replacing the source identity", async () => { + const user = userEvent.setup(); + const get = vi.fn().mockResolvedValueOnce(changes({ run_id: "another-run" })).mockResolvedValue(changes()); + show(get); + await screen.findByText("Could not load this turn’s changes"); + expect(screen.queryByText("5 files edited")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("5 files edited"); + expect(get.mock.calls[1][1]).toEqual({ source_event_id: "first-reply" }); +}); diff --git a/web/src/v2/components/turn-changes.tsx b/web/src/v2/components/turn-changes.tsx new file mode 100644 index 00000000..0370c66d --- /dev/null +++ b/web/src/v2/components/turn-changes.tsx @@ -0,0 +1,134 @@ +import { useEffect, useId, useMemo, useRef, useState, type RefObject } from "react"; +import { createPortal } from "react-dom"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, ChevronUp, FileDiff, X } from "lucide-react"; +import type { APIClient } from "../../api/client"; +import type { ThreadChangeFile, ThreadChangeSummary } from "../../api/thread-changes"; +import { parseUnifiedDiff } from "../../components/unified-diff"; +import { useModalFocusTrap } from "../../hooks/use-modal-focus-trap"; +import { useLocale } from "../../lib/locale"; +import "./turn-changes.css"; + +function ChangeCounts({ value }: { value: Pick }) { + const { t, locale } = useLocale(); + return value.counts_available ? + +{value.additions.toLocaleString(locale)} + −{value.deletions.toLocaleString(locale)} + : {t("行数不可用", "Counts unavailable")}; +} + +function fileLabel(file: ThreadChangeFile) { + return file.destination_path ? `${file.path} → ${file.destination_path}` : file.path; +} + +function ChangeDiffDialog({ summary, initialIndex, onClose, returnFocusRef }: { + summary: ThreadChangeSummary; initialIndex: number; onClose: () => void; + returnFocusRef: RefObject; +}) { + const { t } = useLocale(); + const [selected, setSelected] = useState(initialIndex); + const [visibleLines, setVisibleLines] = useState(500); + const titleID = useId(); + const close = useRef(null); + const dialog = useModalFocusTrap(true, onClose, false, close, + { isolateBackground: true, returnFocusRef }); + const file = summary.files[selected]; + const diff = useMemo(() => parseUnifiedDiff(file.diff), [file.diff]); + return createPortal(
    { + if (event.target === event.currentTarget) onClose(); + }}> +
    +
    +
    +
    + +
    +
    {fileLabel(file)}
    + {file.reason &&

    {file.reason === "secrets_redacted" + ? t("此文件含敏感内容,未展示差异及行数。", "This file contains sensitive content. Its diff and line counts are hidden.") + : file.reason === "non_contiguous_edits" + ? t("这段编辑记录不连续,无法确定本轮的完整差异。", "These edit records are not continuous, so the full diff for this turn is unavailable.") + : t("暂时无法计算此文件的差异及行数。", "The diff and line counts for this file could not be calculated.")}

    } + {file.diff_truncated &&

    {t("差异超过显示上限,未完整展示。", "The diff exceeds the display limit and is not shown in full.")}

    } + {file.diff ?
    + {diff.lines.slice(0, visibleLines).map((line, index) =>
    + + {line.marker === "+" || line.marker === "-" ? line.marker : " "}{line.text || " "} +
    )} + {diff.lines.length > visibleLines && } +
    : !file.reason && !file.diff_truncated &&

    {t("此改动没有文本差异。", "This change has no text diff.")}

    } +
    +
    +
    {t("比较本轮已记录编辑的起始内容与最终内容。", "Compares the initial and final content of edits recorded in this turn.")}
    +
    +
    , document.body); +} + +export function V2TurnChanges({ client, threadID, runID, sourceEventID }: { + client: APIClient; threadID: string; runID: string; sourceEventID: string; +}) { + const { t } = useLocale(); + const container = useRef(null); + const returnFocusRef = useRef(null); + const [visible, setVisible] = useState(false); + const [expanded, setExpanded] = useState(false); + const [selected, setSelected] = useState(null); + const listID = useId(); + // Historical replies load their summaries as they approach the viewport. + useEffect(() => { + if (!container.current) return; + if (typeof IntersectionObserver === "undefined") { setVisible(true); return; } + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { setVisible(true); observer.disconnect(); } + }, { rootMargin: "300px" }); + observer.observe(container.current); + return () => observer.disconnect(); + }, []); + const query = useQuery({ queryKey: ["thread", threadID, "turn-changes", sourceEventID], + queryFn: ({ signal }) => client.threadChanges(threadID, runID, sourceEventID, signal), + enabled: visible, staleTime: Infinity, retry: false }); + const summary = query.data; + const open = (index: number, trigger: HTMLElement) => { returnFocusRef.current = trigger; setSelected(index); }; + + if (summary && !summary.files.length && !summary.partial) return null; + return
    + {query.isError &&
    + {t("本轮改动加载失败", "Could not load this turn’s changes")} + +
    } + {summary &&
    +
    + +

    {summary.files.length ? t(`已编辑 ${summary.file_count} 个文件`, `${summary.file_count} ${summary.file_count === 1 ? "file" : "files"} edited`) + : t("本轮改动", "Changes in this turn")}

    + {summary.files.length > 0 && } +
    +
      + {(expanded ? summary.files : summary.files.slice(0, 3)).map((file, index) =>
    • + +
    • )} +
    + {summary.files.length > 3 && } + {summary.partial &&

    {t("部分改动详情不可用", "Some change details are unavailable")}

    } +
    } + {summary && selected !== null && setSelected(null)} returnFocusRef={returnFocusRef} />} +
    ; +} diff --git a/web/src/v2/projection/narrative.test.ts b/web/src/v2/projection/narrative.test.ts index 764dcf21..d3566b0d 100644 --- a/web/src/v2/projection/narrative.test.ts +++ b/web/src/v2/projection/narrative.test.ts @@ -531,6 +531,7 @@ describe("projectThreadNarrative", () => { runId: "run-1", text: "第一部分内容。\n\n第二部分内容。", branchSourceID: "part-2", + changeSourceID: "part-1", })); }); }); diff --git a/web/src/v2/projection/narrative.ts b/web/src/v2/projection/narrative.ts index c70a391d..9d704138 100644 --- a/web/src/v2/projection/narrative.ts +++ b/web/src/v2/projection/narrative.ts @@ -41,6 +41,7 @@ export type NarrativeEntry = text: string; runId?: string; branchSourceID?: string; + changeSourceID?: string; createdAt: string; provisional: boolean; } @@ -305,10 +306,12 @@ export function projectThreadNarrative( // not show an identical durable answer twice in the conversation. if (previous.text !== text) previous.text = `${previous.text}\n\n${text}`; previous.branchSourceID = branchSourceID; + previous.changeSourceID ??= branchSourceID; } else { result.push({ id: item.id, kind: "assistant", text, runId: item.run_id, branchSourceID, + changeSourceID: branchSourceID, createdAt: item.created_at, provisional: item.provisional }); } continue; From f7212c3432cc7f2a7ed89d3a8efe984ae4567185 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:51:59 +0800 Subject: [PATCH 3/3] docs: describe reply change summaries and align streaming test locale --- docs/usage.md | 5 +++++ web/src/v2/components/narrative-streaming.test.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index c182691c..f5ff00b2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -116,6 +116,11 @@ Ctrl+Shift+V(macOS 为 Cmd+Shift+V)按纯文本粘贴,保留 Markdown 标 实时回复的新片段以 150 ms 的轻微模糊淡入显示,不额外等待或延迟输出;历史消息和 代码块直接显示。开启系统的“减少动态效果”后,文字直接出现。 +AI 回复下方的“本轮改动”卡片汇总本轮已应用的文件编辑,显示文件数和净增删行数。 +默认展示三个文件,可展开全部;点击文件或“查看变更”可查看本轮起始内容与最终内容的差异。 +统计范围为文件编辑工具留下的应用记录;命令直接写入的文件不在此范围。敏感内容或不连续的 +记录会标明差异与行数不可用,没有净改动时不显示卡片。 + Agent 建议固定任务要求时,问题会显示在输入框上方的独立卡片中。可选择“本次任务始终保留” 或“仅当前讨论”,也可直接撰写回复,再点击“发送”。自定义回复只有勾选“将这段回复固定到本次任务” 才会成为固定要求;“跳过”不固定内容,也不发送答复。回答失败时可重试原答复。 diff --git a/web/src/v2/components/narrative-streaming.test.tsx b/web/src/v2/components/narrative-streaming.test.tsx index 9d5e7e96..98b93297 100644 --- a/web/src/v2/components/narrative-streaming.test.tsx +++ b/web/src/v2/components/narrative-streaming.test.tsx @@ -28,7 +28,7 @@ it("reveals only new appends and does not replay a loaded, corrected or complete expect(reveal()).toBeNull(); view.rerender(content("已更正。最终回复。", false)); expect(reveal()).toBeNull(); - expect(screen.getByRole("button", { name: "复制回复" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy response" })).toBeInTheDocument(); }); it("keeps Markdown emphasis and file links intact as unfinished markup closes", () => { @@ -62,7 +62,7 @@ it("leaves code stable during streaming and copies the original code and reply", fireEvent.click(screen.getByRole("button", { name: "复制代码" })); await waitFor(() => expect(writeText).toHaveBeenLastCalledWith("const one = 1;\nconst two = 2;\n")); view.rerender(content(full, false)); - fireEvent.click(screen.getByRole("button", { name: "复制回复" })); + fireEvent.click(screen.getByRole("button", { name: "Copy response" })); await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(full)); });