diff --git a/cmd/entire/cli/agent/antigravity/transcript.go b/cmd/entire/cli/agent/antigravity/transcript.go index 71688994b..84ca09ac5 100644 --- a/cmd/entire/cli/agent/antigravity/transcript.go +++ b/cmd/entire/cli/agent/antigravity/transcript.go @@ -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//.system_generated/logs/transcript.jsonl // The on-disk schema is a sequence of "step" objects: @@ -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)\s*(.*?)\s*`) + +// extractUserRequest returns the inner text of the first 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 +} + +// 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 diff --git a/cmd/entire/cli/agent/antigravity/transcript_test.go b/cmd/entire/cli/agent/antigravity/transcript_test.go index 44744c87b..9c01a7b89 100644 --- a/cmd/entire/cli/agent/antigravity/transcript_test.go +++ b/cmd/entire/cli/agent/antigravity/transcript_test.go @@ -3,8 +3,10 @@ package antigravity import ( "bytes" "context" + "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -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":"\nread a.txt and exit\n\n\nThe current local time is: x.\n"}`, + `{"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":"first"}`, + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","content":"ok"}`, + `{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"second"}`, + } + 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":"go"}`, + `{"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) +} diff --git a/cmd/entire/cli/integration_test/antigravity_test.go b/cmd/entire/cli/integration_test/antigravity_test.go index 09e914f59..cf7e51701 100644 --- a/cmd/entire/cli/integration_test/antigravity_test.go +++ b/cmd/entire/cli/integration_test/antigravity_test.go @@ -9,10 +9,16 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/trailers" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -119,6 +125,143 @@ func TestAntigravity_FullEventFlow(t *testing.T) { "PreToolUse(write_to_file foo.txt) should have populated files_touched; got %v", got) } +// TestAntigravity_PromptInCheckpointMetadata proves the condensation-time +// late-flush prompt fallback end-to-end for Antigravity (agy). +// +// Background: agy writes its JSONL transcript AFTER the Stop hook fires, so the +// TurnEnd prompt backfill (lifecycle.go) reads an EMPTY transcript and prompt.txt +// stays empty. The committed "prompt" field would therefore be empty. The +// condensation-time fallback (resolvePromptsFromLateFlushedTranscript) re-extracts +// the prompt from the live transcript at `git commit` time — by then agy has +// finished its asynchronous write, so the transcript is populated. +// +// The test reproduces that exact timing: +// 1. TurnStart (PreInvocation, invocationNum 0) — transcript file is EMPTY. +// 2. PreToolUse write_to_file touches foo.txt; the file is written to the worktree. +// 3. Stop (fullyIdle true) — SaveStep creates the shadow checkpoint while the +// transcript is STILL EMPTY (proving the backfill cannot recover the prompt). +// 4. The transcript is populated with a USER_INPUT/ step BEFORE the +// git commit (simulating agy finishing its late write). +// 5. git commit → PostCommit condensation → the committed session metadata's +// prompt is recovered from the now-populated transcript. +func TestAntigravity_PromptInCheckpointMetadata(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + env.InitEntire() + + const requestText = "add a foo.txt file with the word bar in it" + + conversationID := "antigravity-it-prompt-conv-id" + transcriptPath := filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", + "brain", conversationID, ".system_generated", "logs", "transcript.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o750)) + + // CRUX: transcript is EMPTY at TurnStart and TurnEnd. agy writes it after Stop. + require.NoError(t, os.WriteFile(transcriptPath, []byte{}, 0o600), + "transcript must start empty to simulate agy's late flush") + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + // TurnStart: first model invocation of the conversation (invocationNum=0). + preInv := mergeMaps(common, map[string]any{ + "invocationNum": 0, + "initialNumSteps": 1, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-invocation", preInv), + "pre-invocation hook should succeed and lazy-init session state") + + statePath := filepath.Join(env.RepoDir, ".git", "entire-sessions", conversationID+".json") + require.FileExists(t, statePath, "session state file should exist after pre-invocation") + + // PreToolUse write_to_file: records foo.txt in state.FilesTouched. + preTU := mergeMaps(common, map[string]any{ + "toolCall": map[string]any{ + "name": "write_to_file", + "args": map[string]any{ + "TargetFile": "foo.txt", + "Overwrite": false, + }, + }, + "stepIdx": 1, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-tool-use", preTU), + "pre-tool-use hook should record the new file in state.FilesTouched") + + // The agent actually writes the file to the worktree so there is a diff to + // checkpoint and later commit. + env.WriteFile("foo.txt", "bar\n") + + // Stop (fullyIdle=true): TurnEnd → SaveStep creates the shadow checkpoint. + // The transcript is STILL EMPTY here, so the TurnEnd prompt backfill recovers + // nothing and prompt.txt is empty. This is what makes the test exercise the + // condensation-time fallback rather than the backfill path. + stopIdle := mergeMaps(common, map[string]any{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "stop", stopIdle), + "stop hook with fullyIdle=true should emit SessionEnd and SaveStep the checkpoint") + + // LATE FLUSH: agy finishes writing the transcript AFTER Stop, BEFORE the commit. + // Now the live transcript carries the USER_INPUT step with the . + step := map[string]any{ + "step_index": 0, + "source": "USER_EXPLICIT", + "type": "USER_INPUT", + "status": "DONE", + "content": "\n" + requestText + "\n", + } + stepJSON, err := json.Marshal(step) + require.NoError(t, err) + require.NoError(t, os.WriteFile(transcriptPath, append(stepJSON, '\n'), 0o600), + "populate the transcript before commit to simulate agy's completed late write") + + // Commit → PostCommit condensation. The fallback re-extracts the prompt from + // the now-populated live transcript. + env.GitCommitWithShadowHooks("Add foo.txt", "foo.txt") + + // Resolve the checkpoint ID from the user commit's Entire-Checkpoint trailer. + headHash := env.GetHeadHash() + repo, err := git.PlainOpen(env.RepoDir) + require.NoError(t, err) + commitObj, err := repo.CommitObject(plumbing.NewHash(headHash)) + require.NoError(t, err) + checkpointID, found := trailers.ParseCheckpoint(commitObj.Message) + require.True(t, found, "user commit should carry an Entire-Checkpoint trailer") + + // Sanity-check that the checkpoint's session metadata.json was written (the + // checkpoint exists and is sharded under the checkpoint ID). + metadataPath := SessionMetadataPath(checkpointID.String()) + metadataContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, metadataPath) + require.True(t, found, "session metadata.json should exist at %s", metadataPath) + var metadata checkpoint.CommittedMetadata + require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata), + "session metadata.json should parse") + require.Equal(t, conversationID, metadata.SessionID, + "checkpoint should be linked to the antigravity conversation/session") + + // PRIMARY ASSERTION: the committed session-level "prompt" (prompt.txt on + // entire/checkpoints/v1) was recovered by the condensation-time late-flush + // fallback. The transcript was empty at TurnEnd (so the backfill recovered + // nothing) and only populated before commit, so a non-empty prompt here can + // ONLY have come from resolvePromptsFromLateFlushedTranscript at condensation. + promptPath := SessionFilePath(checkpointID.String(), paths.PromptFileName) + promptContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath) + require.True(t, found, "prompt.txt should exist at %s", promptPath) + require.NotEmpty(t, strings.TrimSpace(promptContent), + "committed prompt.txt must be non-empty — the condensation-time late-flush "+ + "fallback should have recovered it from the populated transcript") + assert.Equal(t, requestText, strings.TrimSpace(promptContent), + "committed prompt should equal the text from the late-flushed transcript") +} + func runAntigravityHook(t *testing.T, repoDir, hookName string, input map[string]any) error { t.Helper() inputJSON, err := json.Marshal(input) diff --git a/cmd/entire/cli/strategy/late_flush_prompt_test.go b/cmd/entire/cli/strategy/late_flush_prompt_test.go new file mode 100644 index 000000000..b0f49f737 --- /dev/null +++ b/cmd/entire/cli/strategy/late_flush_prompt_test.go @@ -0,0 +1,53 @@ +package strategy + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" + "github.com/stretchr/testify/require" +) + +// TestResolvePromptsFromLateFlushedTranscript verifies that the condensation-time +// fallback re-extracts user prompts directly from a populated transcript via the +// agent's PromptExtractor. This covers late-flushing agents (e.g. Antigravity) +// whose transcript is empty at TurnEnd but populated by condensation time, so +// prompt.txt is empty and the only remaining source is the live transcript. +func TestResolvePromptsFromLateFlushedTranscript(t *testing.T) { + t.Parallel() + + // Real agy transcript with a USER_INPUT step wrapping the prompt in a + // block, matching agy's on-disk step schema. + transcript := `{"step_index":0,"source":"SYSTEM","type":"CONVERSATION_HISTORY","content":"boot"} +{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"Add a login button"} +{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","content":"working on it"} +` + dir := t.TempDir() + transcriptPath := filepath.Join(dir, "transcript.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o600)) + + ag := antigravity.NewAntigravityAgent() + // Sanity: the real agent must be a PromptExtractor for this fallback to fire. + _, ok := agent.AsPromptExtractor(ag) + require.True(t, ok, "antigravity agent must implement PromptExtractor") + + got := resolvePromptsFromLateFlushedTranscript(context.Background(), ag, transcriptPath, 0) + require.Equal(t, []string{"Add a login button"}, got) +} + +// TestResolvePromptsFromLateFlushedTranscript_Guards verifies the helper returns +// nil for the no-op cases callers rely on (empty path, non-extractor agent). +func TestResolvePromptsFromLateFlushedTranscript_Guards(t *testing.T) { + t.Parallel() + + ag := antigravity.NewAntigravityAgent() + + // Empty path → nil, no extraction attempted. + require.Nil(t, resolvePromptsFromLateFlushedTranscript(context.Background(), ag, "", 0)) + + // Nil agent → nil (AsPromptExtractor returns false). + require.Nil(t, resolvePromptsFromLateFlushedTranscript(context.Background(), nil, "/nonexistent", 0)) +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 3e58960cd..5c3cb98bf 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -836,6 +836,12 @@ func (s *ManualCommitStrategy) extractSessionData(ctx context.Context, repo *git if len(data.Prompts) == 0 { data.Prompts = readPromptsFromFilesystem(ctx, sessionID) } + // Late-flush fallback: re-extract from the populated live transcript when + // prompt.txt is still empty (e.g. Antigravity writes the transcript after + // the Stop hook, so the TurnEnd prompt backfill saw an empty file). + if len(data.Prompts) == 0 { + data.Prompts = resolvePromptsFromLateFlushedTranscript(ctx, ag, liveTranscriptPath, checkpointTranscriptStart) + } } // Use tracked files from session state (not all files in tree) @@ -875,6 +881,11 @@ func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context. data.Transcript = liveData data.FullTranscriptLines = countTranscriptItems(state.AgentType, fullTranscript) data.Prompts = readPromptsFromFilesystem(ctx, state.SessionID) + // Late-flush fallback: re-extract from the live transcript when prompt.txt is + // still empty (e.g. Antigravity writes the transcript after the Stop hook). + if len(data.Prompts) == 0 { + data.Prompts = resolvePromptsFromLateFlushedTranscript(ctx, ag, transcriptPath, state.CheckpointTranscriptStart) + } // Resolve files touched: prefers hook-populated state, falls back to transcript extraction data.FilesTouched = s.resolveFilesTouched(ctx, state) @@ -887,6 +898,29 @@ func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context. return data, nil } +// resolvePromptsFromLateFlushedTranscript re-extracts user prompts directly +// from a populated transcript at condensation time. Agents like Antigravity +// write their transcript AFTER the Stop hook, so the TurnEnd prompt backfill +// (lifecycle.go) saw an empty transcript and prompt.txt is empty. By +// condensation the live transcript is populated. General — any PromptExtractor +// benefits; callers only invoke this when prompts are otherwise empty. +func resolvePromptsFromLateFlushedTranscript(ctx context.Context, ag agent.Agent, transcriptPath string, offset int) []string { + if transcriptPath == "" { + return nil + } + extractor, ok := agent.AsPromptExtractor(ag) + if !ok { + return nil + } + prompts, err := extractor.ExtractPrompts(transcriptPath, offset) + if err != nil { + logging.Warn(ctx, "condensation prompt extraction failed", + slog.String("error", err.Error())) + return nil + } + return prompts +} + // countTranscriptItems counts lines (JSONL) or messages (JSON) in a transcript. // For Claude Code and JSONL-based agents, this counts lines. // For Gemini CLI, OpenCode, and JSON-based agents, this counts messages.