Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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
42 changes: 32 additions & 10 deletions cmd/entire/cli/agent/opencode/entire_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ 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
}
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 @@ -77,6 +92,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
if (currentSessionID !== session.id) {
seenUserMessages.clear()
messageStore.clear()
sessionsWithOpenTurn.clear()
currentModel = null
const json = JSON.stringify({
session_id: session.id,
Expand All @@ -98,6 +114,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 +136,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 @@ -132,6 +149,9 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
if (props?.status?.type !== "idle") break
const sessionID = props?.sessionID ?? currentSessionID
if (!sessionID) 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 +175,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 +192,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
30 changes: 30 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,36 @@ 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, "sessionsWithOpenTurn.delete(sessionID)") {
t.Fatal("plugin file missing turn-end session cleanup")
}
}

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 @@ -248,6 +248,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(): some protected dirs (like
// .claude/) can contain user-managed files, while these paths are managed by
// Entire or the OpenCode runtime 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" || strings.HasPrefix(p, ".opencode/") {
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
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ func calculateSessionAttributions(ctx context.Context, repo *git.Repository, sha
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
Comment thread
pfleidi marked this conversation as resolved.
Outdated
}
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 @@ -1807,8 +1807,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 @@ -1975,15 +1980,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
33 changes: 25 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,13 @@ 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.json": {},
".claude/settings.json": {},
}

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

require.Contains(t, resultSet, "docs/blue.md")
require.Contains(t, resultSet, "docs/red.md")
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, 4)
}

func TestMarshalPromptAttributionsIncludingPending_IncludesPending(t *testing.T) {
Expand Down Expand Up @@ -4807,8 +4811,21 @@ 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"},
)

require.Equal(t, []string{"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