From 4f76997c8a0a7218dfcc58858201685eda25b4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 22 Sep 2026 19:03:21 +0100 Subject: [PATCH] fix: resume ACP sessions without history replay (#3859) Co-authored-by: Kandev Agent --- apps/backend/go.mod | 2 +- apps/backend/go.sum | 4 +- .../agent/runtime/lifecycle/session.go | 14 +- apps/backend/internal/agentctl/AGENTS.md | 4 +- .../adapter/transport/acp/adapter_session.go | 13 +- .../transport/acp/adapter_session_resume.go | 59 +++++ .../acp/adapter_session_resume_test.go | 208 ++++++++++++++++++ docs/task_session_resume.md | 35 +-- 8 files changed, 308 insertions(+), 31 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume_test.go diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 05a617b018..a3378c6c91 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -148,4 +148,4 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect ) -replace github.com/coder/acp-go-sdk => github.com/kdlbs/acp-go-sdk v0.13.6-0.20260821092416-13e3f4dc12c2 +replace github.com/coder/acp-go-sdk => github.com/kdlbs/acp-go-sdk v0.13.6-0.20260922084253-07a3e52f8179 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 77f30c35c2..f9af78193d 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -243,8 +243,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kdlbs/acp-go-sdk v0.13.6-0.20260821092416-13e3f4dc12c2 h1:aWN8W+iAeSlt5iIf+YwzyZ55VevQO0jKp6TvhL818vs= -github.com/kdlbs/acp-go-sdk v0.13.6-0.20260821092416-13e3f4dc12c2/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= +github.com/kdlbs/acp-go-sdk v0.13.6-0.20260922084253-07a3e52f8179 h1:pRuzXC7qPQMAseD8bxcukDqRVJ89wwtmQ1T0uVL8fw8= +github.com/kdlbs/acp-go-sdk v0.13.6-0.20260922084253-07a3e52f8179/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index 0657c8556d..60b5c10b96 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -140,7 +140,7 @@ type InitializeResult struct { // It handles the initialize handshake and session creation/loading based on config. // // Session behavior: -// - If agentConfig.Runtime().SessionConfig.NativeSessionResume is true AND existingSessionID is provided: use session/load +// - If agentConfig.Runtime().SessionConfig.NativeSessionResume is true AND existingSessionID is provided: restore via the adapter // - If NativeSessionResume is false (CLI handles resume): always use session/new // - Otherwise: use session/new func (sm *SessionManager) InitializeSession( @@ -295,7 +295,7 @@ func (sm *SessionManager) getResumeContextPrompt(agentConfig agents.Agent, taskS return resumePrompt } -// loadSession loads an existing session via ACP session/load +// loadSession restores an existing session using the adapter's compatible ACP request. func (sm *SessionManager) loadSession( ctx context.Context, client *agentctl.Client, @@ -303,7 +303,7 @@ func (sm *SessionManager) loadSession( sessionID string, mcpServers []agentctltypes.McpServer, ) (string, error) { - sm.logger.Info("sending ACP session/load request", + sm.logger.Info("restoring existing ACP session", zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID)) @@ -315,12 +315,12 @@ func (sm *SessionManager) loadSession( // classification. The caller still classifies canceled loads as // transport-dead and skips the session/new fallback. if errors.Is(err, context.Canceled) { - sm.logger.Warn("ACP session/load aborted by context", + sm.logger.Warn("ACP session restoration aborted by context", zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID), zap.Error(err)) } else { - sm.logger.Error("ACP session/load failed", + sm.logger.Error("ACP session restoration failed", zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID), zap.Error(err)) @@ -328,7 +328,7 @@ func (sm *SessionManager) loadSession( return "", fmt.Errorf("session/load failed: %w", err) } - sm.logger.Info("ACP session loaded successfully", + sm.logger.Info("ACP session restored successfully", zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID)) @@ -365,7 +365,7 @@ func (sm *SessionManager) createNewSession( // InitializeAndPrompt performs full ACP session initialization and sends the initial prompt. // This offices: -// 1. Session initialization (initialize + session/new or session/load) +// 1. Session initialization (initialize + creation or restoration through the adapter) // 2. Publishing ACP session created event // 3. Connecting WebSocket streams // 4. Sending the initial task prompt (if provided) diff --git a/apps/backend/internal/agentctl/AGENTS.md b/apps/backend/internal/agentctl/AGENTS.md index 485e299b8a..0ed81458b3 100644 --- a/apps/backend/internal/agentctl/AGENTS.md +++ b/apps/backend/internal/agentctl/AGENTS.md @@ -86,7 +86,9 @@ Grok ACP currently exposes neither per-turn cost nor subscription quota/reset va ## ACP Protocol -JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/prompt`, `session/cancel`, `session/close`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`. +JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/resume`, `session/prompt`, `session/cancel`, `session/close`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`. + +The adapter prefers advertised `session/resume` for any agent to restore the saved conversation without replaying its history. Both resume and load responses preserve typed configuration and legacy model state. If an agent advertises resume but returns method-not-found, the adapter uses `session/load` only when advertised and the context is still active. Other errors preserve the saved identity. Restore traces contain separate `session.resume` and `session.load` spans for the actual requests. ### ACP permission identity and injected MCP approval diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go index 5cb003c03e..4ebf87ae2d 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go @@ -439,8 +439,8 @@ func mapToHTTPHeaders(headers map[string]string) []acp.HttpHeader { return hdrs } -// LoadSession resumes an existing session. -// Returns an error if the agent does not support session loading (LoadSession capability). +// LoadSession restores an existing session, preferring advertised session/resume +// without history replay and otherwise using session/load. // mcpServers are passed to the agent so it can reconnect to MCP servers on the new // agentctl instance (critical for agents that receive MCP configs via the protocol). // @@ -453,15 +453,14 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers a.mu.Lock() conn := a.acpConn - supportsLoad := a.capabilities.LoadSession + capabilities := a.capabilities a.mu.Unlock() if conn == nil { return fmt.Errorf("adapter not initialized") } - // Check if the agent supports session loading - if !supportsLoad { + if !capabilities.LoadSession && capabilities.SessionCapabilities.Resume == nil { a.logger.Debug("session/load rejected: agent does not advertise LoadSession capability", zap.String("session_id", sessionID)) return fmt.Errorf("agent does not support session loading (LoadSession capability is false)") @@ -481,7 +480,7 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers a.mu.Unlock() a.cancelAllAsyncTurnCompletes() - ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.load") + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.restore") defer span.End() // Filter MCP servers by agent capabilities (same logic as NewSession). @@ -505,7 +504,7 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers delete(a.usageBySession, sessionID) a.mu.Unlock() - resp, err := conn.LoadSession(ctx, acp.LoadSessionRequest{ + resp, err := a.restoreSessionState(ctx, conn, capabilities, acp.LoadSessionRequest{ SessionId: acp.SessionId(sessionID), Cwd: a.cfg.WorkDir, McpServers: toACPMcpServers(filteredServers), diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume.go new file mode 100644 index 0000000000..42266fa225 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume.go @@ -0,0 +1,59 @@ +package acp + +import ( + "context" + "errors" + + "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "go.opentelemetry.io/otel/attribute" + "go.uber.org/zap" +) + +// restoreSessionState uses advertised session/resume to restore the provider +// conversation without transferring history that Kandev already stores. +func (a *Adapter) restoreSessionState( + ctx context.Context, + conn *acp.ClientSideConnection, + capabilities acp.AgentCapabilities, + req acp.LoadSessionRequest, +) (acp.LoadSessionResponse, error) { + if capabilities.SessionCapabilities.Resume == nil { + return a.loadSessionWithReplay(ctx, conn, req) + } + a.logger.Info("resuming session without history replay", zap.String("session_id", string(req.SessionId))) + resumeCtx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.resume") + span.SetAttributes(attribute.String("session_id", string(req.SessionId))) + resp, err := conn.ResumeSession(resumeCtx, acp.ResumeSessionRequest{ + SessionId: req.SessionId, + Cwd: req.Cwd, + McpServers: req.McpServers, + }) + if err != nil { + span.RecordError(err) + } + span.End() + var requestErr *acp.RequestError + if ctx.Err() == nil && capabilities.LoadSession && errors.As(err, &requestErr) && requestErr.Code == -32601 { + a.logger.Warn("advertised session/resume unsupported, loading session history", + zap.String("session_id", string(req.SessionId))) + return a.loadSessionWithReplay(ctx, conn, req) + } + return acp.LoadSessionResponse(resp), err +} + +// loadSessionWithReplay traces the history transfer separately from a preceding resume attempt. +func (a *Adapter) loadSessionWithReplay( + ctx context.Context, + conn *acp.ClientSideConnection, + req acp.LoadSessionRequest, +) (acp.LoadSessionResponse, error) { + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.load") + defer span.End() + span.SetAttributes(attribute.String("session_id", string(req.SessionId))) + resp, err := conn.LoadSession(ctx, req) + if err != nil { + span.RecordError(err) + } + return resp, err +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume_test.go new file mode 100644 index 0000000000..6657b91a39 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_resume_test.go @@ -0,0 +1,208 @@ +package acp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "testing" + + acpsdk "github.com/coder/acp-go-sdk" + acpclient "github.com/kandev/kandev/internal/agentctl/server/acp" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +type sessionResumeAgent struct { + sessionRequestCaptureAgent + resumeRequest acpsdk.ResumeSessionRequest + resumeError error + resumeResponse *acpsdk.ResumeSessionResponse + resumeCalls int + loadResponse acpsdk.LoadSessionResponse +} + +func (a *sessionResumeAgent) LoadSession(ctx context.Context, req acpsdk.LoadSessionRequest) (acpsdk.LoadSessionResponse, error) { + _, err := a.sessionRequestCaptureAgent.LoadSession(ctx, req) + return a.loadResponse, err +} + +func (a *sessionResumeAgent) ResumeSession(_ context.Context, req acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) { + a.resumeCalls++ + a.resumeRequest = req + if a.resumeResponse != nil { + return *a.resumeResponse, a.resumeError + } + var response acpsdk.ResumeSessionResponse + if err := json.Unmarshal([]byte(`{ + "modes":{"currentModeId":"code","availableModes":[{"id":"code","name":"Code"}]}, + "configOptions":[{"type":"select","id":"model","name":"Model","category":"model", + "currentValue":"saved-model","options":[{"value":"saved-model","name":"Saved model"}]}], + "_meta":{"restored":true} + }`), &response); err != nil { + return response, err + } + return response, a.resumeError +} + +func newSessionResumeAdapter(t *testing.T, load, resume bool) (*Adapter, *sessionResumeAgent) { + t.Helper() + toAgent, fromClient := io.Pipe() + t.Cleanup(func() { _ = toAgent.Close(); _ = fromClient.Close() }) + toClient, fromAgent := io.Pipe() + t.Cleanup(func() { _ = toClient.Close(); _ = fromAgent.Close() }) + fake := &sessionResumeAgent{} + conn := acpsdk.NewClientSideConnection(acpclient.NewClient(), fromClient, toClient) + _ = acpsdk.NewAgentSideConnection(fake, fromAgent, toAgent) + a := newTestAdapter() + t.Cleanup(func() { _ = a.Close() }) + a.agentID = codexAgentID + a.dialect = newACPDialect(codexAgentID) + a.acpConn = conn + a.cfg.WorkDir = t.TempDir() + a.capabilities.LoadSession = load + a.capabilities.McpCapabilities = acpsdk.McpCapabilities{Http: true, Sse: true} + if resume { + a.capabilities.SessionCapabilities.Resume = &acpsdk.SessionResumeCapabilities{} + } + return a, fake +} + +func TestLoadSessionPrefersAdvertisedResume(t *testing.T) { + for _, load := range []bool{true, false} { + t.Run(fmt.Sprintf("load_supported_%t", load), func(t *testing.T) { + a, fake := newSessionResumeAdapter(t, load, true) + servers := []types.McpServer{ + {Name: "kandev", Type: "http", URL: "http://localhost:10005/mcp"}, + {Name: "kandev", Type: "sse", URL: "http://localhost:10005/sse"}, + } + if err := a.LoadSession(t.Context(), "saved-session", servers); err != nil { + t.Fatalf("LoadSession: %v", err) + } + if fake.resumeCalls != 1 || fake.loadRequest.SessionId != "" || fake.sessionCounter != 0 { + t.Fatalf("want one resume without replay or replacement, got resume=%d load=%q new=%d", + fake.resumeCalls, fake.loadRequest.SessionId, fake.sessionCounter) + } + if fake.resumeRequest.SessionId != "saved-session" || fake.resumeRequest.Cwd != a.cfg.WorkDir { + t.Fatalf("resume lost session identity or cwd: %+v", fake.resumeRequest) + } + assertCapturedKandevTransport(t, fake.resumeRequest.McpServers, "http") + if a.GetSessionID() != "saved-session" || a.isLoadingSession { + t.Fatal("resume did not settle the saved session") + } + events := drainEvents(a) + models := findSessionModelsEvent(t, events) + if models.CurrentModelID != "saved-model" || len(models.ConfigOptions) != 1 { + t.Fatalf("resume lost model/config state: %+v", models) + } + if state := a.GetSessionModelState(); state == nil || state.CurrentModelID != "saved-model" { + t.Fatalf("cached model state = %+v", state) + } + var resumed, mode bool + for _, event := range events { + resumed = resumed || event.SessionStatus == streams.SessionStatusResumed + mode = mode || event.CurrentModeID == "code" + } + if !resumed || !mode { + t.Fatalf("resume events missing: resumed=%t mode=%t", resumed, mode) + } + }) + } +} + +func TestLoadSessionFallsBackToReplayOnlyWhenResumeUnsupported(t *testing.T) { + for _, advertised := range []bool{true, false} { + t.Run(fmt.Sprintf("resume_advertised_%t", advertised), func(t *testing.T) { + a, fake := newSessionResumeAdapter(t, true, advertised) + fake.resumeError = acpsdk.NewMethodNotFound(acpsdk.AgentMethodSessionResume) + fake.loadResponse.LegacyModels = &acpsdk.LegacyModels{ + CurrentModelId: "legacy-model", + AvailableModels: []acpsdk.LegacyModelInfo{{ModelId: "legacy-model", Name: "Legacy model"}}, + } + if err := a.LoadSession(t.Context(), "saved-session", nil); err != nil { + t.Fatalf("LoadSession: %v", err) + } + if fake.loadRequest.SessionId != "saved-session" || fake.sessionCounter != 0 { + t.Fatalf("want replay of saved session, got load=%q new=%d", fake.loadRequest.SessionId, fake.sessionCounter) + } + if advertised != (fake.resumeCalls == 1) { + t.Fatalf("resume calls=%d advertised=%t", fake.resumeCalls, advertised) + } + if event := findSessionModelsEvent(t, drainEvents(a)); event.CurrentModelID != "legacy-model" { + t.Fatalf("load fallback lost legacy model state: %+v", event) + } + }) + } +} + +func TestLoadSessionResumesLegacyModelsAcrossAgents(t *testing.T) { + for _, agentID := range []string{"auggie", claudeAgentID, "unknown-agent"} { + t.Run(agentID, func(t *testing.T) { + a, fake := newSessionResumeAdapter(t, true, true) + a.agentID = agentID + a.dialect = newACPDialect(agentID) + fake.resumeResponse = &acpsdk.ResumeSessionResponse{} + if err := json.Unmarshal([]byte(`{"models":{ + "currentModelId":"legacy-model", + "availableModels":[{"modelId":"legacy-model","name":"Legacy model"}] + }}`), fake.resumeResponse); err != nil { + t.Fatal(err) + } + if err := a.LoadSession(t.Context(), "saved-session", nil); err != nil { + t.Fatalf("LoadSession: %v", err) + } + if fake.resumeCalls != 1 || fake.loadRequest.SessionId != "" || fake.sessionCounter != 0 { + t.Fatalf("want resume without replay, got resume=%d load=%q new=%d", + fake.resumeCalls, fake.loadRequest.SessionId, fake.sessionCounter) + } + models := findSessionModelsEvent(t, drainEvents(a)) + if models.CurrentModelID != "legacy-model" || len(models.SessionModels) != 1 { + t.Fatalf("resume lost legacy model state: %+v", models) + } + if state := a.GetSessionModelState(); state == nil || len(state.Models) != 1 || state.Models[0].ModelID != "legacy-model" { + t.Fatalf("cached model state = %+v", state) + } + }) + } +} + +func TestLoadSessionDoesNotLoadWhenReplayUnsupported(t *testing.T) { + a, fake := newSessionResumeAdapter(t, false, true) + a.dialect = newACPDialect("auggie") + a.agentID = "auggie" + a.sessionID = "prior-session" + fake.resumeError = acpsdk.NewMethodNotFound(acpsdk.AgentMethodSessionResume) + if err := a.LoadSession(t.Context(), "saved-session", nil); err == nil { + t.Fatal("expected unsupported session loading") + } + if fake.resumeCalls != 1 || fake.loadRequest.SessionId != "" || fake.sessionCounter != 0 { + t.Fatal("failed resume retried an unsupported method") + } + if a.GetSessionID() != "prior-session" || a.isLoadingSession { + t.Fatal("failed resume changed identity or retained replay suppression") + } +} + +func TestLoadSessionResumeFailurePreservesIdentity(t *testing.T) { + for _, cause := range []error{ + acpsdk.NewInternalError(map[string]any{"error": "context deadline exceeded"}), + acpsdk.NewAuthRequired(nil), + acpsdk.NewRequestCancelled(nil), + } { + t.Run(cause.Error(), func(t *testing.T) { + a, fake := newSessionResumeAdapter(t, true, true) + fake.resumeError = cause + a.sessionID = "prior-session" + if err := a.LoadSession(t.Context(), "saved-session", nil); err == nil { + t.Fatal("expected resume error") + } + if fake.resumeCalls != 1 || fake.loadRequest.SessionId != "" || fake.sessionCounter != 0 { + t.Fatalf("inconclusive error retried: resume=%d load=%q new=%d", + fake.resumeCalls, fake.loadRequest.SessionId, fake.sessionCounter) + } + if a.GetSessionID() != "prior-session" || a.isLoadingSession { + t.Fatal("failed resume changed identity or retained replay suppression") + } + }) + } +} diff --git a/docs/task_session_resume.md b/docs/task_session_resume.md index 5dbe1efd9e..233c064de8 100644 --- a/docs/task_session_resume.md +++ b/docs/task_session_resume.md @@ -12,7 +12,7 @@ Two resume strategies exist depending on agent capabilities: | Strategy | When used | What happens | |----------|-----------|--------------| -| **Native resume** (ACP `session/load`) | Agent has `NativeSessionResume: true` and a stored resume token | Agent restores its own conversation context via ACP protocol | +| **Native resume** (ACP `session/resume` or `session/load`) | Agent has `NativeSessionResume: true` and a stored resume token | Agent restores its own conversation context via ACP protocol | | **History injection** (fresh start) | Agent has `HistoryContextInjection: true`, no resume token | Agent starts fresh; conversation history is injected into the first user prompt | Agents with neither flag simply boot idle with no context restoration. @@ -123,7 +123,7 @@ This is where the system decides whether to auto-prompt the agent: ```go // Case A: Native resume -- has resume token if running.ResumeToken != "" && startAgent { - req.ACPSessionID = running.ResumeToken // triggers session/load + req.ACPSessionID = running.ResumeToken // restores the saved ACP session req.TaskDescription = "" // don't auto-prompt } @@ -154,21 +154,30 @@ Runs `StartAgentProcess` in a background goroutine. On success, the callback res 1. Wait for agentctl HTTP server ready (60s timeout) 2. Configure agent (command, env, working directory) 3. Start agent subprocess via agentctl -4. Initialize ACP session (`session/new` or `session/load`) +4. Initialize ACP session (`session/new`, `session/resume`, or `session/load`) 5. Call `dispatchInitialPrompt()` -### ACP session creation: `session/load` vs `session/new` +### ACP session restoration and creation **`internal/agent/lifecycle/session.go`** ~line 118 ``` If agent has NativeSessionResume: true AND existingSessionID is non-empty: - -> Try session/load (ACP protocol) - -> On failure (method not found / capability false): fallback to session/new + -> Agent advertises resume: session/resume without history replay + -> Method not found, load advertised, context active: session/load of the same session + -> Agent does not advertise resume: session/load + -> Only explicitly unsupported or unknown-session errors may fall back to session/new + -> Transport, authentication, cancellation, and inconclusive errors preserve the saved identity Otherwise: -> session/new ``` +Replay-free resume is selected by the agent's advertised capability. Both restore +responses retain typed configuration and legacy model state. Kandev already +stores the displayed conversation, so an agent can restore its provider context +without transferring that history again. Traces distinguish the actual +`acp.session.resume` and `acp.session.load` requests beneath `acp.session.restore`. + ### `dispatchInitialPrompt` (three-way switch) **`internal/agent/lifecycle/session.go`** ~line 338 @@ -183,7 +192,7 @@ Otherwise: ## History Injection -For agents that cannot natively restore their session (no ACP `session/load`), conversation history can be injected into the first user prompt after resume. This is opt-in via the `HistoryContextInjection` flag on `SessionConfig`. +For agents that cannot natively restore their session, conversation history can be injected into the first user prompt after resume. This is opt-in via the `HistoryContextInjection` flag on `SessionConfig`. ### Recording @@ -306,12 +315,12 @@ The session is NOT marked as failed -- the user can send a new message to start | Agent | NativeSessionResume | HistoryContextInjection | Notes | |-------|:---:|:---:|-------| | Claude Code | - | - | Uses `--resume` CLI flag with stored resume token | -| Codex | yes | - | ACP `session/load` restores context | -| Copilot | yes | - | ACP `session/load` restores context | -| Auggie | yes | - | ACP `session/load` restores context (v0.18.1+) | +| Codex | yes | - | Prefers advertised ACP `session/resume` without history replay; falls back to `session/load` when resume is unsupported | +| Copilot | yes | - | Prefers advertised ACP `session/resume`; otherwise uses `session/load` | +| Auggie | yes | - | Prefers advertised ACP `session/resume`; otherwise uses `session/load` (v0.18.1+) | | Gemini | - | - | Boots idle, no context restoration | | Amp | - | - | Uses `ForkSessionCmd` / `ContinueSessionCmd` | -| OpenCode | yes | - | ACP `session/load` restores context | +| OpenCode | yes | - | Prefers advertised ACP `session/resume`; otherwise uses `session/load` | Agent configs are defined in `internal/agent/agents/`. @@ -325,7 +334,7 @@ Persists runtime state for a session across backend restarts: | Field | Purpose | |-------|---------| -| `ResumeToken` | ACP session ID (used for `session/load` or `--resume` CLI flag) | +| `ResumeToken` | ACP session ID (used for `session/resume`, `session/load`, or `--resume` CLI flag) | | `LastMessageUUID` | For `--resume-session-at` flag | | `ContainerID` | Docker container ID (for recovery) | | `AgentctlURL` / `AgentctlPort` | agentctl connection info | @@ -338,7 +347,7 @@ The resume token is stored/updated in `storeResumeToken()` (`internal/orchestrat - `handleSessionStatusEvent` -- on session status updates - `handleCompleteStreamEvent` -- on stream completion -The token is always stored regardless of `NativeSessionResume`. Agents with native resume use it for ACP `session/load`; others (e.g., Claude Code) use it for their `--resume` CLI flag. +The token is always stored regardless of `NativeSessionResume`. Agents with native resume use it for ACP `session/resume` or `session/load`; others (e.g., Claude Code) use it for their `--resume` CLI flag. ---