diff --git a/docs/architecture/agent-gui-node.md b/docs/architecture/agent-gui-node.md index 2140c92cf4..c2e83156e1 100644 --- a/docs/architecture/agent-gui-node.md +++ b/docs/architecture/agent-gui-node.md @@ -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 diff --git a/docs/conventions/troubleshooting/agent-session-lifecycle.md b/docs/conventions/troubleshooting/agent-session-lifecycle.md index f461354508..562d707e07 100644 --- a/docs/conventions/troubleshooting/agent-session-lifecycle.md +++ b/docs/conventions/troubleshooting/agent-session-lifecycle.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/packages/agent/daemon/runtime/controller_session_lifecycle.go b/packages/agent/daemon/runtime/controller_session_lifecycle.go index 2b27efafd9..32581d941d 100644 --- a/packages/agent/daemon/runtime/controller_session_lifecycle.go +++ b/packages/agent/daemon/runtime/controller_session_lifecycle.go @@ -3,6 +3,7 @@ package agentruntime import ( "context" "fmt" + "reflect" "strings" activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events" @@ -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 } diff --git a/packages/agent/daemon/runtime/controller_session_resume_test.go b/packages/agent/daemon/runtime/controller_session_resume_test.go index 0a1beeab3c..50d742e75d 100644 --- a/packages/agent/daemon/runtime/controller_session_resume_test.go +++ b/packages/agent/daemon/runtime/controller_session_resume_test.go @@ -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() @@ -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 { @@ -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 }