Skip to content
Open
158 changes: 155 additions & 3 deletions cmd/entire/cli/agent/antigravity/transcript.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
package antigravity

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

// Compile-time interface assertions.
var (
_ agent.PromptExtractor = (*AntigravityAgent)(nil)
_ agent.TranscriptAnalyzer = (*AntigravityAgent)(nil)
)

// Antigravity 2.0 (agy) writes JSONL transcripts at
// ~/.gemini/antigravity-cli/brain/<conversation-id>/.system_generated/logs/transcript.jsonl
// The on-disk schema is a sequence of "step" objects:
Expand All @@ -21,9 +31,151 @@ import (
// "content": string (optional — user request / model text),
// "tool_calls": [ { "name": string, "args": object } ] (optional)
// }
// v1 ships only the JSONL chunk/reassemble passthrough; field-aware decoding
// (token counting, file-change replay, prompt extraction) is deferred to a
// follow-up plan. See testdata/transcript_sample.jsonl for a captured fixture.
// Prompt extraction and field-aware modified-file/position analysis
// (TranscriptAnalyzer) are implemented below. ReadTranscript/Chunk/Reassemble
// remain JSONL passthrough, and token counting is handled out-of-band
// elsewhere. See testdata/transcript_sample.jsonl for a captured fixture.

// agyStep is one line of agy's step-based JSONL transcript.
type agyStep struct {
StepIndex int `json:"step_index"`
Source string `json:"source"`
Type string `json:"type"`
Content string `json:"content"`
ToolCalls []agyStepToolCall `json:"tool_calls"`
}

type agyStepToolCall struct {
Name string `json:"name"`
Args map[string]json.RawMessage `json:"args"`
}

var userRequestRe = regexp.MustCompile(`(?s)<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>`)

// extractUserRequest returns the inner text of the first <USER_REQUEST> block,
// or the whole trimmed content if no wrapper is present.
func extractUserRequest(content string) string {
if m := userRequestRe.FindStringSubmatch(content); m != nil {
return strings.TrimSpace(m[1])
}
// No wrapper: assume the content is itself the prompt. A hypothetical
// metadata-only USER_INPUT step would surface verbatim — acceptable for v1.
return strings.TrimSpace(content)
}

// ExtractPrompts implements agent.PromptExtractor. agy's PreInvocation hook
// carries no prompt, so the user prompt is recovered from the transcript's
// USER_INPUT steps. fromOffset is a count of non-blank lines already consumed.
func (a *AntigravityAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // path supplied by agent hook stdin
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("antigravity: read transcript for prompts: %w", err)
}
var prompts []string
lineNum := 0
for _, raw := range bytes.Split(data, []byte("\n")) {
if len(bytes.TrimSpace(raw)) == 0 {
continue // skip blank lines BEFORE counting (matches codex splitJSONL)
}
lineNum++
if lineNum <= fromOffset {
continue
}
var step agyStep
if json.Unmarshal(raw, &step) != nil {
continue
}
if step.Type != "USER_INPUT" {
continue
}
if text := extractUserRequest(step.Content); text != "" {
prompts = append(prompts, text)
}
}
return prompts, nil
}

// GetTranscriptPosition implements agent.TranscriptAnalyzer. It returns the
// number of non-blank JSONL lines in the transcript, which the framework uses
// as a stable offset to bound subsequent extraction to a single checkpoint
// range. A missing file yields (0, nil) so a not-yet-flushed transcript (agy
// writes asynchronously) doesn't fail the hook.
func (a *AntigravityAgent) GetTranscriptPosition(path string) (int, error) {
if path == "" {
return 0, nil
}
data, err := os.ReadFile(path) //nolint:gosec // path supplied by agent hook stdin
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("antigravity: transcript position: %w", err)
}
count := 0
for _, line := range bytes.Split(data, []byte("\n")) {
if len(bytes.TrimSpace(line)) > 0 {
count++
}
}
return count, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transcript offset counting mismatch

Medium Severity

Antigravity’s GetTranscriptPosition, ExtractPrompts, and ExtractModifiedFilesFromOffset advance offsets using non-blank JSONL lines, but after condensation CheckpointTranscriptStart is set from countTranscriptItems, which counts physical newline-separated lines (only trailing empties trimmed). Comparing those values in hasNewTranscriptWork and passing checkpointTranscriptStart into the new late-flush prompt path can miss new transcript work or scope prompts/files incorrectly when blank lines appear.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a530791. Configure here.

}

// ExtractModifiedFilesFromOffset implements agent.TranscriptAnalyzer. It scans
// agy step lines after startOffset for mutating tool calls and returns the
// target file paths they touch, deduplicated, alongside the new line position.
//
// Path convention: returned paths are ABSOLUTE and symlink-resolved — the same
// shape lifecycle.go's parsePreToolUse records into FilesTouched. The framework
// relativizes downstream via FilterAndNormalizePaths -> paths.ToRelativePath
// against the worktree root, so we must NOT pre-relativize here. We mirror
// parsePreToolUse exactly: decode the double-encoded TargetFile arg, then
// resolveAgySymlinks so the path matches what attribution diffs against (e.g.
// macOS /tmp -> /private/tmp). Both helpers live in lifecycle.go (same package)
// and are reused, not duplicated.
//
// The blank-skip -> lineNum++ -> (lineNum <= startOffset) ordering matches
// ExtractPrompts so positions stay consistent across analyzer methods.
func (a *AntigravityAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) {
if path == "" {
return nil, 0, nil
}
data, readErr := os.ReadFile(path) //nolint:gosec // path supplied by agent hook stdin
if readErr != nil {
if os.IsNotExist(readErr) {
return nil, 0, nil
}
return nil, 0, fmt.Errorf("antigravity: extract modified files: %w", readErr)
}
seen := map[string]bool{}
lineNum := 0
for _, raw := range bytes.Split(data, []byte("\n")) {
if len(bytes.TrimSpace(raw)) == 0 {
continue // skip blank lines BEFORE counting (matches ExtractPrompts)
}
lineNum++
if lineNum <= startOffset {
continue
}
var step agyStep
if json.Unmarshal(raw, &step) != nil {
continue
}
for _, tc := range step.ToolCalls {
switch tc.Name {
case "write_to_file", "replace_file_content", "multi_replace_file_content":
target := resolveAgySymlinks(decodeAgyString(tc.Args["TargetFile"]))
if target != "" && !seen[target] {
seen[target] = true
files = append(files, target)
}
}
}
}
return files, lineNum, nil
}

func (a *AntigravityAgent) ReadTranscript(sessionRef string) ([]byte, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // path supplied by agent hook stdin
Expand Down
189 changes: 189 additions & 0 deletions cmd/entire/cli/agent/antigravity/transcript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package antigravity
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -82,3 +84,190 @@ func TestPrepareTranscript_EmptyRefIsNoOp(t *testing.T) {
t.Errorf("PrepareTranscript(\"\") should not error, got %v", err)
}
}

func TestExtractPrompts_StripsUserRequestWrapper(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "transcript.jsonl")
lines := []string{
`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"<USER_REQUEST>\nread a.txt and exit\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>"}`,
`{"step_index":1,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}`,
`{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"ok"}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
a := &AntigravityAgent{}
prompts, err := a.ExtractPrompts(path, 0)
if err != nil {
t.Fatalf("ExtractPrompts: %v", err)
}
if len(prompts) != 1 {
t.Fatalf("want 1 prompt, got %d: %#v", len(prompts), prompts)
}
if prompts[0] != "read a.txt and exit" {
t.Errorf("want stripped request, got %q", prompts[0])
}
}

func TestExtractPrompts_RespectsOffsetAndMissingFile(t *testing.T) {
t.Parallel()
a := &AntigravityAgent{}
got, err := a.ExtractPrompts(filepath.Join(t.TempDir(), "nope.jsonl"), 0)
if err != nil || got != nil {
t.Fatalf("missing file: want (nil,nil), got (%#v,%v)", got, err)
}
}

func TestExtractPrompts_RealFixture(t *testing.T) {
t.Parallel()
a := &AntigravityAgent{}
prompts, err := a.ExtractPrompts("testdata/transcript_sample.jsonl", 0)
if err != nil {
t.Fatalf("ExtractPrompts: %v", err)
}
if len(prompts) != 1 || prompts[0] != "read a.txt and tell me what it says, then exit" {
t.Fatalf("unexpected prompts: %#v", prompts)
}
}

func TestExtractPrompts_SkipsLinesAtOrBelowOffset(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "t.jsonl")
lines := []string{
`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"<USER_REQUEST>first</USER_REQUEST>"}`,
`{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","content":"ok"}`,
`{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"<USER_REQUEST>second</USER_REQUEST>"}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
a := &AntigravityAgent{}

// offset 0 → both prompts
all, err := a.ExtractPrompts(path, 0)
if err != nil {
t.Fatalf("ExtractPrompts(0): %v", err)
}
if len(all) != 2 || all[0] != "first" || all[1] != "second" {
t.Fatalf("offset 0: want [first second], got %#v", all)
}

// offset 1 → first non-blank line consumed, so only the second USER_INPUT remains
rest, err := a.ExtractPrompts(path, 1)
if err != nil {
t.Fatalf("ExtractPrompts(1): %v", err)
}
if len(rest) != 1 || rest[0] != "second" {
t.Fatalf("offset 1: want [second], got %#v", rest)
}
}

func TestGetTranscriptPosition_CountsLines(t *testing.T) {
t.Parallel()
a := &AntigravityAgent{}
pos, err := a.GetTranscriptPosition("testdata/transcript_sample.jsonl")
if err != nil {
t.Fatal(err)
}
if pos <= 0 {
t.Fatalf("want > 0 lines, got %d", pos)
}
if p, e := a.GetTranscriptPosition(filepath.Join(t.TempDir(), "no.jsonl")); p != 0 || e != nil {
t.Fatalf("missing: want (0,nil) got (%d,%v)", p, e)
}
}

func TestExtractModifiedFiles_FromToolCalls(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "t.jsonl")
lines := []string{
`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"<USER_REQUEST>go</USER_REQUEST>"}`,
`{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"/repo/a.txt\"","Overwrite":"true"}}]}`,
`{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"replace_file_content","args":{"TargetFile":"\"/repo/b.txt\""}}]}`,
`{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/repo\""}}]}`,
// Re-mutate /repo/a.txt on a later step: must be deduplicated, not double-counted.
`{"step_index":4,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"/repo/a.txt\"","Overwrite":"true"}}]}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
a := &AntigravityAgent{}
files, pos, err := a.ExtractModifiedFilesFromOffset(path, 0)
if err != nil {
t.Fatal(err)
}
if pos != 5 {
t.Errorf("want pos 5, got %d", pos)
}
want := map[string]bool{"/repo/a.txt": true, "/repo/b.txt": true}
if len(files) != 2 {
t.Fatalf("want 2 modified files (deduped), got %#v", files)
}
for _, f := range files {
if !want[f] {
t.Errorf("unexpected modified file %q", f)
}
}
}

// TestExtractModifiedFiles_PathConvention pins the path convention: the
// analyzer returns ABSOLUTE, symlink-resolved paths (the same shape
// lifecycle.go's parsePreToolUse records into FilesTouched). The framework
// relativizes downstream via FilterAndNormalizePaths -> paths.ToRelativePath
// against the worktree root, so returning absolute here is correct and must
// NOT be pre-relativized. This test creates a real file under a temp dir and
// asserts the returned path is the absolute, symlink-resolved location.
func TestExtractModifiedFiles_PathConvention(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Resolve symlinks on the temp dir itself (macOS /tmp -> /private/tmp) so
// our expectation matches what resolveAgySymlinks produces.
resolvedDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
target := filepath.Join(resolvedDir, "sub", "real.txt")
if mkErr := os.MkdirAll(filepath.Dir(target), 0o750); mkErr != nil {
t.Fatal(mkErr)
}
if wErr := os.WriteFile(target, []byte("x"), 0o600); wErr != nil {
t.Fatal(wErr)
}

transcript := filepath.Join(dir, "t.jsonl")
// Note the double-encoded TargetFile arg, mirroring agy's wire format.
line := `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":` +
jsonQuote(t, jsonQuote(t, target)) + `,"Overwrite":"true"}}]}`
if wErr := os.WriteFile(transcript, []byte(line+"\n"), 0o600); wErr != nil {
t.Fatal(wErr)
}

a := &AntigravityAgent{}
files, _, err := a.ExtractModifiedFilesFromOffset(transcript, 0)
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("want 1 file, got %#v", files)
}
if !filepath.IsAbs(files[0]) {
t.Errorf("expected an absolute path, got %q", files[0])
}
if files[0] != target {
t.Errorf("want absolute symlink-resolved path %q, got %q", target, files[0])
}
}

// jsonQuote returns s wrapped as a JSON string literal (used to build the
// double-encoded TargetFile arg in the path-convention test).
func jsonQuote(t *testing.T, s string) string {
t.Helper()
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("jsonQuote(%q): %v", s, err)
}
return string(b)
}
Loading
Loading