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
14 changes: 14 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,20 @@ type LiveSubagentFileReporter interface {
LiveSubagentModifiedFiles(ctx context.Context, agentID, toolUseID string, startedAt time.Time) ([]string, error)
}

// ChildSessionSubagents is implemented by agents whose subagents run as
// sessions of their own (OpenCode child sessions): a child's token usage is
// recorded only in its own session, never in the parent's transcript or its
// subagent snapshot. The completion that claims a child's task record, whether
// a real stop or the SessionEnd sweep, credits that usage to the parent
// session's total, once per call. Do not implement it when the parent's
// transcript already counts its children, or each child would count twice.
type ChildSessionSubagents interface {
Agent

// SubagentUsageOutsideParent marks the capability; it does nothing.
SubagentUsageOutsideParent()
}

// SidecarImageProvider is implemented by agents that keep images OUTSIDE the
// transcript Entire condenses — e.g. Cursor stores pasted images in a per-session
// SQLite blob store, not the JSONL transcript. The strategy layer calls this
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/agent/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type CapabilityDeclarer interface {
// have no DeclaredCaps field:
//
// - CallerSessionIdentifier
// - ChildSessionSubagents
// - ContextInjector
// - EffectiveHookDiagnostics
// - HomeLayoutProvider
Expand Down Expand Up @@ -239,6 +240,12 @@ func AsLiveSubagentFileReporter(ag Agent) (LiveSubagentFileReporter, bool) {
return builtinCapability[LiveSubagentFileReporter](ag)
}

// AsChildSessionSubagents returns the agent as ChildSessionSubagents if it
// implements the interface. Built-in only, like LiveSubagentFileReporter.
func AsChildSessionSubagents(ag Agent) (ChildSessionSubagents, bool) {
return builtinCapability[ChildSessionSubagents](ag)
}

// AsTokenCalculator returns the agent as TokenCalculator if it both
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
func AsTokenCalculator(ag Agent) (TokenCalculator, bool) {
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/agent/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ type Event struct {
// it so a resumed child's export can be cut to the call that produced it.
SubagentStartedAt time.Time

// SessionEndSweep marks the Final SubagentEnd the SessionEnd sweep
// synthesizes for a task still in flight. Its SubagentStartedAt is the
// record's start, which may be the hook's own clock rather than the
// agent's, so a child-session credit (agent.ChildSessionSubagents) records
// the call's start as unknown.
SessionEndSweep bool

// SubagentTranscriptUnavailable records that no child transcript is
// obtainable for this completion — either by contract (Copilot CLI) or
// because the fetch failed (OpenCode). It prevents later generic layout
Expand Down
9 changes: 9 additions & 0 deletions cmd/entire/cli/agent/opencode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,3 +650,12 @@ func hooksInstalledNow(t *testing.T, ag interface {
}
return installed
}

// OpenCode children are sessions of their own, so their usage is credited to
// the parent at completion, including by the SessionEnd sweep.
func TestOpenCodeIsChildSessionSubagents(t *testing.T) {
t.Parallel()
if _, ok := agent.AsChildSessionSubagents(NewOpenCodeAgent()); !ok {
t.Fatal("OpenCode must be agent.ChildSessionSubagents")
}
}
5 changes: 5 additions & 0 deletions cmd/entire/cli/agent/opencode/subagent_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ func isCallPrompt(info MessageInfo, parts []Part) bool {
return true
}

// SubagentUsageOutsideParent marks OpenCode as agent.ChildSessionSubagents:
// a task's child is a session of its own, and the parent's messages never
// include its usage.
func (a *OpenCodeAgent) SubagentUsageOutsideParent() {}

// LiveSubagentModifiedFiles exports a running child to a private staging file
// and returns the files its task call has modified so far. Unlike
// exportSubagent it writes neither the shared export cache nor the call's
Expand Down
9 changes: 6 additions & 3 deletions cmd/entire/cli/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,7 @@ func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingS
// for state written before the token offset existed.
tokenStartOnDisk := state.TokenStart()
pendingOnDisk := state.CheckpointTokenUsage
pendingChildOnDisk := state.PendingChildSessionTokens()
lastCheckpointOnDisk := state.LastCheckpointID
// Read before LastInteractionTime is refreshed below: a running session
// whose agent exited will send no Stop for its turn. Inactivity stands in
Expand Down Expand Up @@ -1454,23 +1455,25 @@ func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingS
}
// A hook that ran while attach waited at its prompt recorded newer token
// state than attach read: a condensation moved the offset or stored the
// pending total (a new checkpoint ID), or a Stop added a turn to the
// pending total. Attach's figures are older than the hook's, so they are
// pending total (a new checkpoint ID), or a Stop added a turn or a
// child session's usage to the pending total. Attach's figures are older than the hook's, so they are
// left out: consuming from the stale position would move the offset back
// and have the next checkpoint recount, and clearing the pending total
// would drop a hook-only agent's turn. Turn start clears LastCheckpointID,
// which is not a checkpoint, so only a new non-empty ID counts.
countedFrom := 0
var pendingRead *agent.TokenUsage
var pendingRead, pendingChildRead *agent.TokenUsage
var lastCheckpointRead id.CheckpointID
if existingState != nil {
countedFrom = existingState.TokenStart()
pendingRead = existingState.CheckpointTokenUsage
pendingChildRead = existingState.PendingChildSessionTokens()
lastCheckpointRead = existingState.LastCheckpointID
}
switch {
case tokenStartOnDisk != countedFrom,
!reflect.DeepEqual(pendingOnDisk, pendingRead),
!reflect.DeepEqual(pendingChildOnDisk, pendingChildRead),
Comment thread
peyton-alt marked this conversation as resolved.
Resolved
lastCheckpointOnDisk != "" && lastCheckpointOnDisk != lastCheckpointRead:
logging.Warn(ctx, "attach: session tokens changed while attach was waiting; leaving token state as the hooks set it",
slog.Int("counted_from", countedFrom), slog.Int("token_start", tokenStartOnDisk))
Expand Down
4 changes: 1 addition & 3 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -2155,9 +2155,7 @@ func formatCheckpointHeader(
tokenUsage = summary.TokenUsage
}
if tokenUsage != nil {
total := tokenUsage.InputTokens + tokenUsage.CacheCreationTokens +
tokenUsage.CacheReadTokens + tokenUsage.OutputTokens
tokensVal := formatTokenCount(total)
tokensVal := formatTokenCount(totalTokens(tokenUsage))
if styles.colorEnabled {
tokensVal = styles.render(styles.yellow, tokensVal)
}
Expand Down
21 changes: 21 additions & 0 deletions cmd/entire/cli/explain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3097,6 +3097,27 @@ func TestFormatCheckpointHeader_TokensFromSummaryFallback(t *testing.T) {
}
}

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

cpID := id.MustCheckpointID("a3b2c4d5e6f7")
meta := checkpoint.Metadata{
SessionID: "s",
CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
TokenUsage: &agent.TokenUsage{
InputTokens: 1000,
SubagentTokens: &agent.TokenUsage{InputTokens: 2000, OutputTokens: 500},
},
}
styles := statusStyles{colorEnabled: false, width: 60}

got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)

if !strings.Contains(got, " tokens 3.5k") {
t.Errorf("expected the tokens row to count subagents, got:\n%s", got)
}
}

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

Expand Down
11 changes: 10 additions & 1 deletion cmd/entire/cli/integration_test/factoryai_token_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,20 @@ func TestFactoryDroidWorkerTokensComeFromItsOwnSettings(t *testing.T) {
require.NotNil(t, rec, "the Worker's turn must be a task on the parent")
require.Equal(t, &agent.TokenUsage{InputTokens: 8612, OutputTokens: 109, CacheReadTokens: 8026, APICallCount: 1}, rec.TokenUsage)

// The parent's own total excludes the Worker, which counts in the task.
// The parent's own TokenUsage excludes the Worker, which counts in the task.
appendLines(t, parent.TranscriptPath,
`{"type":"message","id":"p1","message":{"role":"assistant","content":[{"type":"text","text":"The Worker is done."}]}}`)
writeDroidSettings(t, parent.TranscriptPath, 56099, 1276, 139199)
require.NoError(t, env.SimulateFactoryDroidStop(parent.ID, parent.TranscriptPath))
require.Equal(t, &agent.TokenUsage{InputTokens: 56099, OutputTokens: 1276, CacheReadTokens: 139199, APICallCount: 1},
sessionTokenTotal(t, env, parent.ID))

// The displayed total adds the Worker once, despite its repeated Stop.
state, err = env.GetSessionState(parent.ID)
require.NoError(t, err)
total := state.TotalTokenUsage()
require.NotNil(t, total.SubagentTokens)
require.Equal(t, 8612, total.SubagentTokens.InputTokens)
require.Equal(t, 8026, total.SubagentTokens.CacheReadTokens)
require.Equal(t, 1, total.SubagentTokens.APICallCount)
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ func TestFactoryDroidReadOnlyWorkerReachesParentCheckpoint(t *testing.T) {
require.NoError(t, env.SimulateFactoryDroidUserPromptSubmit(parent.ID))

// A read-only Worker: it reads and reports, consuming tokens, and writes
// nothing.
// nothing. Real Droid transcripts carry no usage: Droid writes it to the
// session's .settings.json, which only #2820 reads. Until then the
// adapter counts transcript usage, so the fixture puts it there; the
// accounting under test is the same wherever the Worker's usage is read.
const toolUseID = "toolu_reader_01"
worker := env.NewFactoryDroidSession()
require.NoError(t, env.SimulateFactoryDroidUserPromptSubmit(worker.ID))
Expand Down Expand Up @@ -210,9 +213,10 @@ func TestFactoryDroidReadOnlyWorkerReachesParentCheckpoint(t *testing.T) {
require.True(t, ok, "the Worker's transcript must be stored with its task")
require.Contains(t, transcript, "It is a test repository.")

if usage := readCommittedTokenUsage(t, env, checkpointID); usage != nil {
require.NotEqual(t, 70, usage.InputTokens, "the parent session's own usage must not absorb the Worker's")
}
usage := readCommittedTokenUsage(t, env, checkpointID)
require.NotNil(t, usage)
require.NotEqual(t, 70, usage.InputTokens, "the parent session's own usage must not absorb the Worker's")
requireCheckpointSubagentTokens(t, env, checkpointID, 70, 7)

// A second Worker turn after that commit: the next checkpoint's task must
// hold only the second turn, not the first again.
Expand All @@ -238,6 +242,26 @@ func TestFactoryDroidReadOnlyWorkerReachesParentCheckpoint(t *testing.T) {
require.NotNil(t, task2.TokenUsage, "the second stored task must carry the Worker's usage: %s", raw)
require.Equal(t, 30, task2.TokenUsage.InputTokens, "the first Worker turn is already in the first checkpoint: %s", raw)
require.Equal(t, 3, task2.TokenUsage.OutputTokens, "the first Worker turn is already in the first checkpoint: %s", raw)
requireCheckpointSubagentTokens(t, env, checkpoint2, 30, 3)

state, err = env.GetSessionState(parent.ID)
require.NoError(t, err)
total := state.TotalTokenUsage()
require.NotNil(t, total)
require.NotNil(t, total.SubagentTokens, "the parent's total counts its Worker")
require.Equal(t, 100, total.SubagentTokens.InputTokens, "each Worker turn once")
require.Equal(t, 10, total.SubagentTokens.OutputTokens, "each Worker turn once")
}

// requireCheckpointSubagentTokens checks the subagent usage a checkpoint's
// session metadata holds: only what completed since the previous checkpoint.
func requireCheckpointSubagentTokens(t *testing.T, env *TestEnv, checkpointID string, input, output int) {
t.Helper()
usage := readCommittedTokenUsage(t, env, checkpointID)
require.NotNil(t, usage)
require.NotNil(t, usage.SubagentTokens, "checkpoint %s must count the Worker", checkpointID)
require.Equal(t, input, usage.SubagentTokens.InputTokens)
require.Equal(t, output, usage.SubagentTokens.OutputTokens)
}

// TestFactoryDroidTopLevelSessionStillCheckpoints guards the fallback: an
Expand Down
128 changes: 128 additions & 0 deletions cmd/entire/cli/integration_test/opencode_subagent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,80 @@ func TestOpenCodeSubagentReusedCallIDRecordsEachCall(t *testing.T) {
}
}

// TestOpenCodeChildTokensCountOnceInParentTotal covers child-session usage,
// which the parent's own export never carries: a foreground child in turn 1,
// whose stop repeats after turn 1 is condensed, then a background child that
// finishes after turn 2 has ended and been committed. Each child must count
// once in the parent's total, in the checkpoint that stores its transcript.
// Every assistant message in these exports is 150 input tokens (see
// CreateOpenCodeTranscript).
func TestOpenCodeChildTokensCountOnceInParentTotal(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)
env.InitEntireWithAgent(agent.AgentNameOpenCode)
parent := env.NewOpenCodeSession()
require.NoError(t, env.SimulateOpenCodeSessionStart(parent.ID, parent.TranscriptPath))

turn := func(prompt, file string) {
t.Helper()
env.WriteFile(file, prompt+"\n")
parent.CreateOpenCodeTranscript(prompt, []FileChange{{Path: file, Content: prompt + "\n"}})
require.NoError(t, env.SimulateOpenCodeTurnEnd(parent.ID, parent.TranscriptPath))
env.GitCommitWithHooks(prompt, file)
}
checkpointSubagentInput := func() int {
t.Helper()
usage := readCommittedTokenUsage(t, env, env.TryGetLatestCheckpointID())
require.NotNil(t, usage)
require.Equal(t, 150, usage.InputTokens, "the parent's own turn")
if usage.SubagentTokens == nil {
return 0
}
return usage.SubagentTokens.InputTokens
}

// Turn 1: a foreground child completes inside the turn.
require.NoError(t, env.SimulateOpenCodeTurnStart(parent.ID, parent.TranscriptPath, "red"))
foreground := env.NewOpenCodeSession()
require.NoError(t, env.SimulateOpenCodeSubagentStart(parent.ID, "call_fg", foreground.ID, "general", "Look"))
env.CopyTranscriptToEntireTmp(foreground.ID, foreground.CreateOpenCodeTranscript("Look", nil))
require.NoError(t, env.SimulateOpenCodeSubagentStop(parent.ID, "call_fg", foreground.ID, "general", "Look"))
turn("red", "docs/red.md")
require.Equal(t, 150, checkpointSubagentInput(), "checkpoint 1 holds the foreground child")

// The same stop again, after condensation removed the child's record.
require.NoError(t, env.SimulateOpenCodeSubagentStop(parent.ID, "call_fg", foreground.ID, "general", "Look"))

// Turn 2: a background child is still running when the turn ends and commits.
require.NoError(t, env.SimulateOpenCodeTurnStart(parent.ID, parent.TranscriptPath, "blue"))
background := env.NewOpenCodeSession()
require.NoError(t, env.SimulateOpenCodeSubagentStart(parent.ID, "call_bg", background.ID, "general", "Review"))
turn("blue", "docs/blue.md")
require.Zero(t, checkpointSubagentInput(), "checkpoint 2 has no new child; turn 1's repeated stop is not counted again")

// The background child goes idle after the parent's turn.
env.CopyTranscriptToEntireTmp(background.ID, background.CreateOpenCodeTranscript("Review", nil))
require.NoError(t, env.SimulateOpenCodeSubagentStop(parent.ID, "call_bg", background.ID, "general", "Review"))
require.NoError(t, env.SimulateOpenCodeSubagentStop(parent.ID, "call_bg", background.ID, "general", "Review"))

// Turn 3: the next checkpoint stores the late child, and only it.
require.NoError(t, env.SimulateOpenCodeTurnStart(parent.ID, parent.TranscriptPath, "green"))
turn("green", "docs/green.md")
require.Equal(t, 150, checkpointSubagentInput(), "checkpoint 3 holds the late child once")
_, ok := env.ReadFileFromBranch(paths.MetadataBranchName, CheckpointTaskFilePath(env.TryGetLatestCheckpointID(), "call_bg", "task.json"))
require.True(t, ok, "the late child's task is stored with the checkpoint that counts its tokens")

state, err := env.GetSessionState(parent.ID)
require.NoError(t, err)
total := state.TotalTokenUsage()
require.NotNil(t, total)
require.Equal(t, 450, total.InputTokens, "the parent's three turns")
require.NotNil(t, total.SubagentTokens)
require.Equal(t, 300, total.SubagentTokens.InputTokens, "each child once")
require.Equal(t, 2, total.SubagentTokens.APICallCount)
}

// TestOpenCodeSubagentRunningChildCoauthorsCommit covers an IDLE OpenCode
// parent whose background child, still running, wrote a file another session
// also claims. The parent has no files of its own yet (the child's reach it at
Expand Down Expand Up @@ -462,3 +536,57 @@ func openCodeActiveParentCoauthorsCommit(t *testing.T, env *TestEnv, beforeCommi
require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary))
require.Len(t, summary.Sessions, 2, "the parent co-authored the commit through its running child")
}

// TestOpenCodeSessionEndCreditsRunningChildOnce covers a background child
// still running when its parent session ends. The SessionEnd sweep completes
// its task and credits the usage its export holds by then (a child that keeps
// working afterwards is undercounted, never counted twice). A later stop for
// the same call, carrying the agent's start, must not credit it again, even
// when the launch had no start and the sweep used the hook's own clock.
func TestOpenCodeSessionEndCreditsRunningChildOnce(t *testing.T) {
t.Parallel()

for _, launchHasStart := range []bool{true, false} {
t.Run(map[bool]string{true: "launch with start", false: "launch without start"}[launchHasStart], func(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)
env.InitEntireWithAgent(agent.AgentNameOpenCode)
parent := env.NewOpenCodeSession()
child := env.NewOpenCodeSession()
child.TimeBase = time.Now().UnixMilli()
launchStart := int64(0)
if launchHasStart {
launchStart = child.TimeBase
}

require.NoError(t, env.SimulateOpenCodeSessionStart(parent.ID, parent.TranscriptPath))
require.NoError(t, env.SimulateOpenCodeTurnStart(parent.ID, parent.TranscriptPath, "review in the background"))
require.NoError(t, env.SimulateOpenCodeSubagentStartAt(parent.ID, "call_bg", child.ID, "general", "Review", launchStart))
if !launchHasStart {
// The record's start is the hook's clock; the child works after it.
child.TimeBase = time.Now().UnixMilli() + 1
}
parent.CreateOpenCodeTranscript("review in the background", nil)
require.NoError(t, env.SimulateOpenCodeTurnEnd(parent.ID, parent.TranscriptPath))
env.CopyTranscriptToEntireTmp(child.ID, child.CreateOpenCodeTranscript("Review", nil))

childInput := func() int {
t.Helper()
state, err := env.GetSessionState(parent.ID)
require.NoError(t, err)
total := state.TotalTokenUsage()
if total == nil || total.SubagentTokens == nil {
return 0
}
return total.SubagentTokens.InputTokens
}

require.NoError(t, env.SimulateOpenCodeSessionEnd(parent.ID, parent.TranscriptPath))
require.Equal(t, 150, childInput(), "the sweep credits the running child")

require.NoError(t, env.SimulateOpenCodeSubagentStopAt(parent.ID, "call_bg", child.ID, "general", "Review", child.TimeBase))
require.Equal(t, 150, childInput(), "the late stop is not credited again")
})
}
}
Loading
Loading