Skip to content
Closed
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
55 changes: 44 additions & 11 deletions cmd/entire/cli/agent/opencode/entire_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
let currentModel: string | null = null
// In-memory store for message metadata (role, tokens, etc.)
const messageStore = new Map<string, any>()
// Track sessions with a started turn that still needs a turn-end hook.
const sessionsWithOpenTurn = new Set<string>()

function maybeStartTurn(sessionID: string | null, messageID: string, prompt: string) {
if (!sessionID || seenUserMessages.has(messageID)) {
return
}
currentSessionID = sessionID
seenUserMessages.add(messageID)
sessionsWithOpenTurn.add(sessionID)
return callHook("turn-start", {
session_id: sessionID,
prompt,
model: currentModel ?? "",
})
}

/**
* Build the shell command for a hook invocation.
Expand Down Expand Up @@ -75,8 +91,16 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
if (!session?.id) break
// Reset per-session tracking state when switching sessions.
if (currentSessionID !== session.id) {
if (currentSessionID) {
// Session switched without an explicit session.deleted event.
// End the previous session to avoid leaving stale ACTIVE state.
callHookSync("session-end", {
session_id: currentSessionID,
})
}
seenUserMessages.clear()
messageStore.clear()
sessionsWithOpenTurn.clear()
currentModel = null
const json = JSON.stringify({
session_id: session.id,
Expand All @@ -98,6 +122,15 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
if (!msg) break
// Store message metadata (role, time, tokens, etc.)
messageStore.set(msg.id, msg)
if (msg.role === "user") {
// Fallback for run-mode where message.part.updated can be absent/late.
// Only use it when message.updated already carries usable text,
// so message.part.updated can still provide the prompt otherwise.
const prompt = typeof msg.content === "string" ? msg.content : null
if (prompt && prompt.trim().length > 0) {
await maybeStartTurn(msg.sessionID ?? currentSessionID, msg.id, prompt)
}
}
// Track model from assistant messages
if (msg.role === "assistant" && msg.modelID) {
currentModel = msg.modelID
Expand All @@ -111,16 +144,8 @@ export const EntirePlugin: Plugin = async ({ directory }) => {

// Fire turn-start on the first text part of a new user message
const msg = messageStore.get(part.messageID)
if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) {
seenUserMessages.add(msg.id)
const sessionID = msg.sessionID ?? currentSessionID
if (sessionID) {
await callHook("turn-start", {
session_id: sessionID,
prompt: part.text ?? "",
model: currentModel ?? "",
})
}
if (msg?.role === "user" && part.type === "text") {
await maybeStartTurn(msg.sessionID ?? currentSessionID, msg.id, part.text ?? "")
}
break
}
Expand All @@ -129,9 +154,15 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
// session.status fires in both TUI and non-interactive (run) mode.
// session.idle is deprecated and not reliably emitted in run mode.
const props = (event as any).properties
if (props?.status?.type !== "idle") break
const statusType = props?.status?.type
if (!statusType) break
const sessionID = props?.sessionID ?? currentSessionID
if (!sessionID) break
currentSessionID = sessionID
if (statusType !== "idle") break
// Ignore duplicate/late idle events when no corresponding turn-start ran.
if (!sessionsWithOpenTurn.has(sessionID)) break
sessionsWithOpenTurn.delete(sessionID)
// Use sync variant: `opencode run` exits on the same idle event,
// so an async hook would be killed before completing.
callHookSync("turn-end", {
Expand All @@ -155,6 +186,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
if (!session?.id) break
seenUserMessages.clear()
messageStore.clear()
sessionsWithOpenTurn.delete(session.id)
currentSessionID = null
// Use sync variant: session-end may fire during shutdown.
callHookSync("session-end", {
Expand All @@ -171,6 +203,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
const sessionID = currentSessionID
seenUserMessages.clear()
messageStore.clear()
sessionsWithOpenTurn.delete(sessionID)
currentSessionID = null
// Use sync variant: this is the last event before process exit.
callHookSync("session-end", {
Expand Down
39 changes: 39 additions & 0 deletions cmd/entire/cli/agent/opencode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,45 @@ func TestInstallHooks_RewritesWhenContentDiffers(t *testing.T) {
}
}

func TestInstallHooks_TurnLifecycleGuardsArePresent(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
ag := &OpenCodeAgent{}

if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("install failed: %v", err)
}

pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts")
data, err := os.ReadFile(pluginPath)
if err != nil {
t.Fatalf("plugin file not created: %v", err)
}

content := string(data)
if !strings.Contains(content, "const sessionsWithOpenTurn = new Set<string>()") {
t.Fatal("plugin file missing open-turn tracking set")
}
if !strings.Contains(content, "await maybeStartTurn(msg.sessionID ?? currentSessionID, msg.id, prompt)") {
t.Fatal("plugin file missing message.updated fallback turn-start")
}
if !strings.Contains(content, "if (!sessionsWithOpenTurn.has(sessionID)) break") {
t.Fatal("plugin file missing turn-end dedupe guard")
}
if !strings.Contains(content, "const statusType = props?.status?.type") {
t.Fatal("plugin file missing session.status type extraction")
}
if !strings.Contains(content, "if (!statusType) break") {
t.Fatal("plugin file missing session.status type guard")
}
if !strings.Contains(content, "sessionsWithOpenTurn.delete(sessionID)") {
t.Fatal("plugin file missing turn-end session cleanup")
}
if !strings.Contains(content, "currentSessionID = sessionID") {
t.Fatal("plugin file missing session tracking update on turn-start")
}
}

func TestUninstallHooks(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
Expand Down
23 changes: 23 additions & 0 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,29 @@ func isProtectedPath(relPath string) bool {
return false
}

// isInternalTrackingPath returns true for runtime/config paths that Entire
// should never attribute as agent-authored project work.
//
// Keep this narrower than isProtectedPath(): protected dirs (like .claude/ or
// .opencode/) can contain user-managed files, while these paths are generated
// by Entire itself.
func isInternalTrackingPath(relPath string) bool {
p := filepath.ToSlash(strings.TrimSpace(relPath))
if p == "" {
return false
}

if strings.HasPrefix(p, ".entire/") || strings.HasPrefix(p, paths.EntireMetadataDir+"/") {
return true
}

if p == ".opencode/plugins/entire.ts" {
return true
}

return false
}

// protectedDirs returns the list of directories to protect. This combines
// static infrastructure dirs with agent-reported dirs from the registry.
// The result is cached via sync.Once since it's called per-file when filtering untracked files.
Expand Down
9 changes: 5 additions & 4 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -739,13 +739,14 @@ func calculateSessionAttributions(ctx context.Context, repo *git.Repository, sha
return attribution
}

// committedFilesExcludingMetadata returns committed files with CLI metadata paths filtered out.
// `.entire/` files are created by `entire enable`, not by the agent, and should not be
// attributed as agent work when used as a fallback for sessions with no FilesTouched.
// committedFilesExcludingMetadata returns committed files with internal tracking paths filtered out.
// Internal files managed by Entire itself (for example, `.entire/...` and the generated
// `.opencode/plugins/entire.ts`) should not be attributed as agent work when this fallback
// is used for sessions with no FilesTouched.
func committedFilesExcludingMetadata(committedFiles map[string]struct{}) []string {
result := make([]string, 0, len(committedFiles))
for f := range committedFiles {
if strings.HasPrefix(f, ".entire/") || strings.HasPrefix(f, paths.EntireMetadataDir+"/") {
if isInternalTrackingPath(f) {
continue
}
result = append(result, f)
Expand Down
12 changes: 10 additions & 2 deletions cmd/entire/cli/strategy/manual_commit_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,12 +319,20 @@ func (s *ManualCommitStrategy) SaveTaskStep(ctx context.Context, step TaskStepCo
func mergeFilesTouched(existing []string, fileLists ...[]string) []string {
seen := make(map[string]bool)
for _, f := range existing {
seen[filepath.ToSlash(f)] = true
norm := filepath.ToSlash(f)
if isInternalTrackingPath(norm) {
continue
}
seen[norm] = true
}

for _, list := range fileLists {
for _, f := range list {
seen[filepath.ToSlash(f)] = true
norm := filepath.ToSlash(f)
if isInternalTrackingPath(norm) {
continue
}
seen[norm] = true
}
}

Expand Down
19 changes: 14 additions & 5 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1818,8 +1818,13 @@ func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(ctx contex
// so callers don't need to prepare the transcript first.
func (s *ManualCommitStrategy) resolveFilesTouched(ctx context.Context, state *SessionState) []string {
if len(state.FilesTouched) > 0 {
result := make([]string, len(state.FilesTouched))
copy(result, state.FilesTouched)
result := make([]string, 0, len(state.FilesTouched))
for _, f := range state.FilesTouched {
if isInternalTrackingPath(f) {
continue
}
result = append(result, f)
}
return result
}

Expand Down Expand Up @@ -1986,15 +1991,19 @@ func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(ctx contex
if basePath != "" {
normalized := make([]string, 0, len(modifiedFiles))
for _, f := range modifiedFiles {
if rel := paths.ToRelativePath(f, basePath); rel != "" {
normalized = append(normalized, filepath.ToSlash(rel))
var rel string
if r := paths.ToRelativePath(f, basePath); r != "" {
rel = filepath.ToSlash(r)
} else if len(f) > 0 && !filepath.IsAbs(f) && f[0] != '/' {
// Already relative — keep as-is
normalized = append(normalized, filepath.ToSlash(f))
rel = filepath.ToSlash(f)
}
// else: absolute path outside repo — skip. These can't match
// committed file paths (which are repo-relative) and would
// create phantom carry-forward branches.
if rel != "" && !isInternalTrackingPath(rel) {
normalized = append(normalized, rel)
}
}
modifiedFiles = normalized
}
Expand Down
36 changes: 28 additions & 8 deletions cmd/entire/cli/strategy/manual_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4713,11 +4713,14 @@ func TestCommittedFilesExcludingMetadata(t *testing.T) {
t.Parallel()

input := map[string]struct{}{
"docs/blue.md": {},
"docs/red.md": {},
".entire/settings.json": {},
".entire/.gitignore": {},
".claude/settings.json": {},
"docs/blue.md": {},
"docs/red.md": {},
".entire/settings.json": {},
".entire/.gitignore": {},
".opencode/plugins/entire.ts": {},
".opencode/settings.json": {},
"opencode.json": {},
".claude/settings.json": {},
}

result := committedFilesExcludingMetadata(input)
Expand All @@ -4730,10 +4733,13 @@ func TestCommittedFilesExcludingMetadata(t *testing.T) {

require.Contains(t, resultSet, "docs/blue.md")
require.Contains(t, resultSet, "docs/red.md")
require.Contains(t, resultSet, ".opencode/settings.json")
require.Contains(t, resultSet, "opencode.json")
require.Contains(t, resultSet, ".claude/settings.json")
require.NotContains(t, resultSet, ".entire/settings.json", ".entire/ should be excluded")
require.NotContains(t, resultSet, ".entire/.gitignore", ".entire/ should be excluded")
require.Len(t, result, 3)
require.NotContains(t, resultSet, ".opencode/plugins/entire.ts", ".opencode/ should be excluded")
require.Len(t, result, 5)
Comment thread
pfleidi marked this conversation as resolved.
}

func TestMarshalPromptAttributionsIncludingPending_IncludesPending(t *testing.T) {
Expand Down Expand Up @@ -4807,8 +4813,22 @@ func TestCommittedFilesExcludingMetadata_AllMetadata(t *testing.T) {
t.Parallel()

result := committedFilesExcludingMetadata(map[string]struct{}{
".entire/settings.json": {},
".entire/.gitignore": {},
".entire/settings.json": {},
".entire/.gitignore": {},
".opencode/plugins/entire.ts": {},
})
require.Empty(t, result, "all metadata files should be excluded")
}

func TestMergeFilesTouched_ExcludesInternalTrackingPaths(t *testing.T) {
t.Parallel()

result := mergeFilesTouched(
[]string{"docs/red.md", ".opencode/plugins/entire.ts"},
[]string{"docs/blue.md", "opencode.json"},
[]string{".entire/settings.json", "docs/green.md"},
[]string{".opencode/settings.json"},
)

require.Equal(t, []string{".opencode/settings.json", "docs/blue.md", "docs/green.md", "docs/red.md", "opencode.json"}, result)
}
9 changes: 5 additions & 4 deletions e2e/tests/attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,13 @@ func TestAttributionMixedHumanAndAgent(t *testing.T) {
cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0)

assert.Equal(t, agentLines, sm.InitialAttribution.AgentLines,
"agent_lines should match actual lines in agent.txt")
assert.InDelta(t, agentLines, sm.InitialAttribution.AgentLines, 1,
"agent_lines should be within one line of actual lines in agent.txt")
assert.Equal(t, humanLines, sm.InitialAttribution.HumanAdded,
"human_added should match lines in human.txt")
assert.Equal(t, agentLines+humanLines, sm.InitialAttribution.TotalCommitted,
"total_committed should be sum of agent and human lines")
expectedTotal := agentLines + humanLines
assert.InDelta(t, expectedTotal, sm.InitialAttribution.TotalCommitted, 1,
"total_committed should be within one line of agent+human sum")
assert.Greater(t, sm.InitialAttribution.AgentPercentage, 0.0,
"agent_percentage should be > 0 when agent wrote content")
assert.Less(t, sm.InitialAttribution.AgentPercentage, 100.0,
Expand Down
8 changes: 5 additions & 3 deletions e2e/tests/edge_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import (
// TestAgentContinuesAfterCommit: agent commits, then makes more changes in a
// second prompt. User commits those. Both commits should have distinct checkpoint IDs.
func TestAgentContinuesAfterCommit(t *testing.T) {
testutil.ForEachAgent(t, 3*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) {
testutil.ForEachAgent(t, 5*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) {
// First prompt — agent creates and commits.
_, err := s.RunPrompt(t, ctx,
"create a markdown file at docs/red.md with a paragraph about the colour red, then commit it. Do not ask for confirmation, just make the change.")
"create a markdown file at docs/red.md with a paragraph about the colour red, then commit it. Do not ask for confirmation, just make the change.",
agents.WithPromptTimeout(2*time.Minute))
if err != nil {
Comment thread
pfleidi marked this conversation as resolved.
t.Fatalf("agent prompt 1 failed: %v", err)
}
Expand All @@ -32,7 +33,8 @@ func TestAgentContinuesAfterCommit(t *testing.T) {

// Second prompt — agent creates another file, user commits.
_, err = s.RunPrompt(t, ctx,
"create a markdown file at docs/blue.md with a paragraph about the colour blue. Do not commit it, only create the file. Do not ask for confirmation, just make the change.")
"create a markdown file at docs/blue.md with a paragraph about the colour blue. Do not commit it, only create the file. Do not ask for confirmation, just make the change.",
agents.WithPromptTimeout(2*time.Minute))
if err != nil {
t.Fatalf("agent prompt 2 failed: %v", err)
}
Expand Down
7 changes: 4 additions & 3 deletions e2e/tests/subagent_commit_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import (
// sessions), session metadata (agent field), and checkpoint existence.
func TestSubagentCommitFlow(t *testing.T) {
testutil.ForEachAgent(t, 3*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) {
prompt := "use a subagent: create a markdown file at docs/red.md with a paragraph about the colour red. Do not commit the file. Do not ask for confirmation, just make the change."
_, err := s.RunPrompt(t, ctx,
"use a subagent: create a markdown file at docs/red.md with a paragraph about the colour red. Do not commit the file. Do not ask for confirmation, just make the change.")
prompt)
if err != nil {
t.Fatalf("agent failed: %v", err)
}
testutil.AssertFileExists(t, s.Dir, "docs/red.md")
testutil.WaitForFileExists(t, s.Dir, "docs/red.md", 30*time.Second)

s.Git(t, "add", ".")
s.Git(t, "add", "docs/red.md")
s.Git(t, "commit", "-m", "Add red.md via subagent")

testutil.WaitForCheckpoint(t, s, 30*time.Second)
Expand Down
Loading