Skip to content
Draft
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
8 changes: 8 additions & 0 deletions docs/architecture/agent-gui-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -2595,6 +2595,14 @@ select/open existing Session

If resume is unavailable, return an explicit state. Do not create a shadow Session.

After the provider reattaches, the daemon compares its live
`SessionState.RuntimeContext` with the restored controller Session. Any newly
recovered provider context, such as context-window usage replayed from provider
history, is merged into the controller Session and emitted through an explicit
session-snapshot report so persistence and AgentGUI hydration observe the same
state. A report prepared from an empty event list is not a substitute: without
an event-derived state patch there is nothing for metadata enrichment to update.

### 7.5 Conversation actions and copy

```text
Expand Down
25 changes: 21 additions & 4 deletions docs/conventions/troubleshooting/agent-session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -3593,7 +3593,9 @@ inline data URL instead`. Claude or standard ACP may instead receive no
several tools, the used-token count may also jump from the latest iteration's
value to nearly the sum of every iteration in that turn. A related lifecycle
symptom is that Claude has rendered its final answer but Agent GUI remains in
the working state until a delayed context-usage control request returns.
the working state until a delayed context-usage control request returns. The
usage footer may also be correct before closing Agent GUI, then disappear
after reopening and resuming the same provider Session.
- Quick checks:
Trace the provider update first: use
`agent_session.claude_sdk.usage_update` for Claude SDK and
Expand All @@ -3615,7 +3617,11 @@ inline data URL instead`. Claude or standard ACP may instead receive no
context occupancy. For a stuck working indicator, compare the Claude SDK
`result` lifecycle timestamp with the later `context_window` usage update and
`turn_completed`; a long result-to-completion gap isolates telemetry on the
terminal path rather than unfinished provider work.
terminal path rather than unfinished provider work. For a resume-only loss,
inspect the provider `SessionState.RuntimeContext` immediately after
`Adapter.Resume`, then inspect the report queued by `Controller.Resume`. If
provider state contains usage but the report has no
`WorkspaceAgentStatePatch`, the recovered observation was never made durable.
- Root cause:
Protocol v2 intentionally removed raw `runtimeContext` from the public
session model. If the refactor removes that legacy field without adding a
Expand All @@ -3635,7 +3641,12 @@ inline data URL instead`. Claude or standard ACP may instead receive no
visible as a false context spike. Conversely, awaiting a correctly bound
`getContextUsage()` call before emitting `turn_completed` makes optional
telemetry a lifecycle dependency and leaves the GUI working for the duration
of a slow control response.
of a slow control response. On resume, a provider may rebuild usage from its
own history after the controller Session has been restored. If the controller
stores only its pre-resume input and emits no explicit snapshot, the live
adapter can briefly expose the right usage while persistence remains stale.
Calling the event-report path with an empty event list does not fix this:
metadata enrichment updates existing state patches but does not create one.
- Fix:
Define usage in the protocol-v2 OpenAPI contract and carry it as typed durable
session metadata through the generated client, desktop adapter, canonical
Expand All @@ -3650,6 +3661,9 @@ inline data URL instead`. Claude or standard ACP may instead receive no
fallback in an asynchronous telemetry path, and invalidate a delayed snapshot
when a newer snapshot request starts or a later root user prompt begins so
older usage cannot overwrite newer work.
After a successful provider resume, merge any changed provider runtime context
into the controller Session and enqueue an explicit session-snapshot report.
Do not use an empty event report for this recovery path.
- Validation:
Cover runtime-context splitting and metadata persistence, generated API
projection, desktop canonical-session adaptation, activity-core usage
Expand All @@ -3662,7 +3676,10 @@ inline data URL instead`. Claude or standard ACP may instead receive no
context snapshot is emitted. Also cover a context query that remains pending:
`turn_completed` must be emitted first, and a delayed snapshot must be dropped
after a newer snapshot request or the next root user prompt. Then run the
Claude SDK sidecar tests, daemon Go tests, AgentGUI tests, and typechecks.
Claude SDK sidecar tests, daemon Go tests, AgentGUI tests, and typechecks. For
resume recovery, cover a state adapter that restores usage during `Resume` and
assert both the returned controller Session and the queued snapshot state
patch contain that usage.
- References:
[agent-activity-packages.md](../architecture/agent-activity-packages.md)
[session_metadata.go](../../packages/agent/store-sqlite/session_metadata.go)
Expand Down
15 changes: 15 additions & 0 deletions packages/agent/daemon/runtime/controller_session_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agentruntime
import (
"context"
"fmt"
"reflect"
"strings"

activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
Expand Down Expand Up @@ -335,11 +336,25 @@ func (c *Controller) Resume(ctx context.Context, input ResumeInput) (Session, er
return Session{}, err
}
session.Status = SessionStatusReady
recoveredRuntimeContext := false
if stateAdapter, ok := adapter.(StateAdapter); ok {
override := stateAdapter.SessionState(session)
if override.RuntimeContext != nil {
merged := mergeRuntimeContextPatch(session.RuntimeContext, override.RuntimeContext)
if !reflect.DeepEqual(merged, session.RuntimeContext) {
session.RuntimeContext = merged
recoveredRuntimeContext = true
}
}
}
c.store(session)
c.publishPendingConfigOptionsUpdates(session)
if !c.publishPendingCommandSnapshot(session) {
c.publishAdapterCommandSnapshot(session, adapter)
}
if recoveredRuntimeContext {
c.enqueueSessionSnapshotReport(ctx, session)
}
return session, nil
}

Expand Down
49 changes: 47 additions & 2 deletions packages/agent/daemon/runtime/controller_session_resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,46 @@ func TestControllerResumeReattachesExistingProviderSession(t *testing.T) {
}
}

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

adapter := newReconnectableAdapter()
adapter.runtimeContext = map[string]any{
"usage": map[string]any{
"contextWindow": map[string]any{
"usedTokens": int64(30_433),
"totalTokens": int64(262_144),
},
},
}
reporter := &recordingReporter{}
controller := NewController([]Adapter{adapter}, reporter)

session, err := controller.Resume(context.Background(), ResumeInput{
RoomID: "room-1",
AgentSessionID: "agent-session-1",
Provider: ProviderClaudeCode,
ProviderSessionID: "provider-session-1",
Title: "Restored",
})
if err != nil {
t.Fatalf("Resume: %v", err)
}
usage := payloadObject(session.RuntimeContext["usage"])
contextWindow := payloadObject(usage["contextWindow"])
if got, _ := int64Value(contextWindow["usedTokens"]); got != 30_433 {
t.Fatalf("resumed usage = %#v, want usedTokens=30433", usage)
}

reports := reporter.waitForCalls(t, 1)
patch := reports[0].report.StatePatches[0]
usage = payloadObject(patch.RuntimeContext["usage"])
contextWindow = payloadObject(usage["contextWindow"])
if got, _ := int64Value(contextWindow["usedTokens"]); got != 30_433 {
t.Fatalf("reported usage = %#v, want usedTokens=30433", usage)
}
}

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

Expand Down Expand Up @@ -243,8 +283,9 @@ func TestControllerResumeRecreatesMissingProviderSessionWhenOptedIn(t *testing.T
}

type reconnectableAdapter struct {
live map[string]bool
resumeCalls int
live map[string]bool
resumeCalls int
runtimeContext map[string]any
}

func newReconnectableAdapter() *reconnectableAdapter {
Expand Down Expand Up @@ -288,6 +329,10 @@ func (a *reconnectableAdapter) HasLiveSession(session Session) bool {
return a.live[session.AgentSessionID]
}

func (a *reconnectableAdapter) SessionState(_ Session) SessionStateSnapshot {
return SessionStateSnapshot{RuntimeContext: clonePayload(a.runtimeContext)}
}

func (a *reconnectableAdapter) dropLiveSession(agentSessionID string) {
a.live[agentSessionID] = false
}
Expand Down
Loading