Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions cmd/entire/cli/integration_test/shell_capture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
//go:build integration

package integration

import (
"os"
"path/filepath"
"runtime"
"slices"
"testing"
)

// These tests cover files an agent changes through a shell command, which no
// transcript analyzer can name, so the worktree is the only evidence.

// TestShellEditToPreExistingUntrackedFile_LinksCommit covers an agent editing,
// through a shell command, a file that was already untracked when the prompt
// started. git status lists it as untracked both before and after, so only the
// size and modification time recorded at prompt start show the turn changed it.
func TestShellEditToPreExistingUntrackedFile_LinksCommit(t *testing.T) {
t.Parallel()
env := NewFeatureBranchEnv(t)

const (
editedFile = "scratch/plan.md"
untouchedFile = "scratch/todo.md"
)
env.WriteFile(editedFile, "# Plan\n")
env.WriteFile(untouchedFile, "# Todo\n")

sess := env.NewSession()
if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
}

// The transcript names no file: the edit is a shell command.
sess.CreateTranscript("rewrite scratch/plan.md with sed", nil)
env.WriteFile(editedFile, "# Plan\n\n1. Rewritten by the agent's shell command.\n")
if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

state, err := env.GetSessionState(sess.ID)
if err != nil || state == nil {
t.Fatalf("GetSessionState failed: %v (state=%v)", err, state)
}
if !containsFile(state.FilesTouched, editedFile) {
t.Errorf("turn end did not record the shell edit to pre-existing untracked %s: FilesTouched=%v",
editedFile, state.FilesTouched)
}
if containsFile(state.FilesTouched, untouchedFile) {
t.Errorf("turn end recorded pre-existing untracked %s, which the turn did not change: FilesTouched=%v",
untouchedFile, state.FilesTouched)
}

env.GitCommitWithHooks("Add plan", editedFile)
if env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) == "" {
t.Errorf("commit of the agent's shell edit to %s should carry an Entire-Checkpoint trailer", editedFile)
}
}

// TestShellRewriteKeepingSizeAndMtime_IsRecorded covers a rewrite that keeps
// the file's length and restores its modification time (as cp -p or touch -r
// would). Only the status-change time shows the edit.
func TestShellRewriteKeepingSizeAndMtime_IsRecorded(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Windows exposes no status-change time; size and mtime are all that is compared there")
}
env := NewFeatureBranchEnv(t)

const editedFile = "scratch/config.ini"
env.WriteFile(editedFile, "mode=aaaa\n")
fullPath := filepath.Join(env.RepoDir, editedFile)
before, err := os.Stat(fullPath)
if err != nil {
t.Fatalf("stat %s: %v", editedFile, err)
}

sess := env.NewSession()
if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
}
sess.CreateTranscript("switch the mode with a shell command", nil)
env.WriteFile(editedFile, "mode=bbbb\n")
if err := os.Chtimes(fullPath, before.ModTime(), before.ModTime()); err != nil {
t.Fatalf("restore mtime: %v", err)
}
if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

state, err := env.GetSessionState(sess.ID)
if err != nil || state == nil {
t.Fatalf("GetSessionState failed: %v (state=%v)", err, state)
}
if !containsFile(state.FilesTouched, editedFile) {
t.Errorf("turn end missed a same-size rewrite of %s that restored its mtime: FilesTouched=%v",
editedFile, state.FilesTouched)
}
}

// TestBackgroundSubagentShellCreatedFile_LinksCommit covers a background
// subagent that creates a file through a shell command after the parent's turn
// has ended. Its transcript names nothing, so only the untracked files that
// appeared since launch show it, and SubagentStop claims those no session has
// recorded.
func TestBackgroundSubagentShellCreatedFile_LinksCommit(t *testing.T) {
t.Parallel()
env := NewFeatureBranchEnv(t)

const (
taskToolUseID = "toolu_01ShellCreated"
subagentID = "a6666777788889999"
createdFile = "reports/summary.txt"
)

sess := env.NewSession()
sess.CreateTranscript("delegate a background report", nil)
launchBackgroundTask(t, env, sess, taskToolUseID, subagentID)
if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

env.WriteFile(createdFile, "generated by the subagent's shell command\n")
stopBackgroundTask(t, env, sess, subagentID)

state, err := env.GetSessionState(sess.ID)
if err != nil || state == nil {
t.Fatalf("GetSessionState failed: %v (state=%v)", err, state)
}
if !containsFile(state.FilesTouched, createdFile) {
t.Errorf("SubagentStop did not record the subagent's shell-created %s: FilesTouched=%v",
createdFile, state.FilesTouched)
}

env.GitCommitWithHooks("Add report", createdFile)
if env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) == "" {
t.Errorf("commit of the subagent's shell-created %s should carry an Entire-Checkpoint trailer", createdFile)
}
}

// TestBackgroundSubagentStop_ClaimsOnlyUnrecordedNewFiles pins which new
// untracked files a background SubagentStop claims for its task. Files that
// existed at launch, files the parent recorded in a later turn, and files
// another session in the worktree recorded are not claimed. A file the user
// created while the parent was idle is claimed along with the subagent's own:
// the worktree cannot tell them apart, and the cost is an extra session link.
func TestBackgroundSubagentStop_ClaimsOnlyUnrecordedNewFiles(t *testing.T) {
t.Parallel()
env := NewFeatureBranchEnv(t)

const (
taskToolUseID = "toolu_01ClaimBoundary"
subagentID = "b7777888899990000"
preLaunchFile = "notes/before-launch.md"
parentFile = "docs/parent-turn-two.md"
otherFile = "docs/other-session.md"
userFile = "notes/user-while-idle.md"
subagentFile = "reports/generated.txt"
)

env.WriteFile(preLaunchFile, "already here\n")

parent := env.NewSession()
parent.CreateTranscript("delegate a background report", nil)
launchBackgroundTask(t, env, parent, taskToolUseID, subagentID)
if err := env.SimulateStop(parent.ID, parent.TranscriptPath); err != nil {
t.Fatalf("parent Stop (turn 1) failed: %v", err)
}

// Parent turn 2 creates a file; its turn end records it.
if err := env.SimulateUserPromptSubmitWithTranscriptPath(parent.ID, parent.TranscriptPath); err != nil {
t.Fatalf("parent prompt (turn 2) failed: %v", err)
}
parent.CreateTranscript("write the parent doc", nil)
env.WriteFile(parentFile, "parent turn two\n")
if err := env.SimulateStop(parent.ID, parent.TranscriptPath); err != nil {
t.Fatalf("parent Stop (turn 2) failed: %v", err)
}

// Another session in the same worktree records its own file.
other := env.NewSession()
if err := env.SimulateUserPromptSubmitWithTranscriptPath(other.ID, other.TranscriptPath); err != nil {
t.Fatalf("other prompt failed: %v", err)
}
other.CreateTranscript("write the other doc", nil)
env.WriteFile(otherFile, "other session\n")
if err := env.SimulateStop(other.ID, other.TranscriptPath); err != nil {
t.Fatalf("other Stop failed: %v", err)
}

// While both sessions are idle: the user creates a file, and the subagent's
// shell command creates another.
env.WriteFile(userFile, "written by the user\n")
env.WriteFile(subagentFile, "generated by the subagent's shell command\n")
stopBackgroundTask(t, env, parent, subagentID)

state, err := env.GetSessionState(parent.ID)
if err != nil || state == nil {
t.Fatalf("GetSessionState failed: %v (state=%v)", err, state)
}
var taskFiles []string
for _, rec := range state.TaskRecords {
if rec.ToolUseID == taskToolUseID {
taskFiles = slices.Sorted(slices.Values(rec.Files))
}
}
want := []string{userFile, subagentFile}
if !slices.Equal(taskFiles, want) {
t.Errorf("task record files = %v, want %v", taskFiles, want)
}
}

// TestOverlappingBackgroundTasks_ClaimNoNewFiles covers two background tasks,
// from different sessions, running at the same time and each creating a file
// through a shell command. Nothing tells the two files apart, so neither stop
// claims new untracked files: the first to stop would otherwise take both.
func TestOverlappingBackgroundTasks_ClaimNoNewFiles(t *testing.T) {
t.Parallel()
env := NewFeatureBranchEnv(t)

const (
firstToolUseID = "toolu_01OverlapFirst"
firstSubagentID = "c1111222233334444"
firstFile = "reports/first.txt"
secondToolUseID = "toolu_01OverlapSecond"
secondAgentID = "c5555666677778888"
secondFile = "reports/second.txt"
)

first := env.NewSession()
first.CreateTranscript("delegate the first report", nil)
launchBackgroundTask(t, env, first, firstToolUseID, firstSubagentID)
if err := env.SimulateStop(first.ID, first.TranscriptPath); err != nil {
t.Fatalf("first Stop failed: %v", err)
}

second := env.NewSession()
second.CreateTranscript("delegate the second report", nil)
launchBackgroundTask(t, env, second, secondToolUseID, secondAgentID)
if err := env.SimulateStop(second.ID, second.TranscriptPath); err != nil {
t.Fatalf("second Stop failed: %v", err)
}

env.WriteFile(firstFile, "first subagent's shell output\n")
env.WriteFile(secondFile, "second subagent's shell output\n")
stopBackgroundTask(t, env, first, firstSubagentID)
stopBackgroundTask(t, env, second, secondAgentID)

for _, sess := range []*Session{first, second} {
state, err := env.GetSessionState(sess.ID)
if err != nil || state == nil {
t.Fatalf("GetSessionState(%s) failed: %v (state=%v)", sess.ID, err, state)
}
if containsFile(state.FilesTouched, firstFile) || containsFile(state.FilesTouched, secondFile) {
t.Errorf("session %s claimed a file that could belong to either overlapping task: FilesTouched=%v",
sess.ID, state.FilesTouched)
}
}
}

func launchBackgroundTask(t *testing.T, env *TestEnv, sess *Session, toolUseID, subagentID string) {
t.Helper()
if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
}
if err := env.SimulatePreTask(sess.ID, sess.TranscriptPath, toolUseID); err != nil {
t.Fatalf("SimulatePreTask failed: %v", err)
}
if err := env.SimulatePostTask(PostTaskInput{
SessionID: sess.ID,
TranscriptPath: sess.TranscriptPath,
ToolUseID: toolUseID,
AgentID: subagentID,
Background: true,
}); err != nil {
t.Fatalf("SimulatePostTask (background stub) failed: %v", err)
}
}

// stopBackgroundTask fires SubagentStop for a subagent whose transcript names
// no file: everything it changed went through shell commands.
func stopBackgroundTask(t *testing.T, env *TestEnv, sess *Session, subagentID string) {
t.Helper()
if err := env.SimulateSubagentStop(SubagentStopInput{
SessionID: sess.ID,
TranscriptPath: sess.TranscriptPath,
AgentID: subagentID,
AgentTranscriptPath: sess.CreateSubagentTranscript(subagentID, nil),
}); err != nil {
t.Fatalf("SimulateSubagentStop failed: %v", err)
}
}
Loading
Loading