Close superseded session after reset
apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go ↗ResetSession now captures the old session and closes it after a successful new session, with guards for concurrency and capability.
ResetSession and close helper
// ResetSession creates a new session on the existing connection, effectively resetting
// the agent's conversation context without restarting the subprocess. This is much faster
// than a full process restart since the ACP protocol supports multiple sessions per connection.
//
// The session/new call spawns a fresh agent-side child process without releasing the
// superseded one, so a successful reset closes the outgoing session to release its
// resources. The old session is captured before NewSession overwrites a.sessionID.
func (a *Adapter) ResetSession(ctx context.Context, mcpServers []types.McpServer) (string, error) {
return a.NewSession(ctx, mcpServers)
a.mu.RLock()
previous, conn := a.sessionID, a.acpConn
a.mu.RUnlock()
newID, err := a.NewSession(ctx, mcpServers)
if err != nil {
return "", err
}
a.closeSupersededSession(ctx, conn, previous, newID)
return newID, nil
}
// closeSupersededSessionTimeout bounds session/close after a reset so cleanup
// can't hang the caller when the agent doesn't respond.
const closeSupersededSessionTimeout = 10 * time.Second
// closeSupersededSession releases the session a successful reset just replaced.
func (a *Adapter) closeSupersededSession(ctx context.Context, conn *acp.ClientSideConnection, previous, newID string) {
if previous == "" || previous == newID || conn == nil {
return
}
a.mu.RLock()
supportsClose := a.capabilities.SessionCapabilities.Close != nil
stillCurrent := a.sessionID == newID
a.mu.RUnlock()
if !supportsClose || !stillCurrent {
return
}
closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), closeSupersededSessionTimeout)
defer cancel()
if _, err := conn.CloseSession(closeCtx, acp.CloseSessionRequest{
SessionId: acp.SessionId(previous),
}); err != nil {
a.logger.Warn("failed to close superseded session after reset",
zap.String("session_id", previous), zap.Error(err))
}
}
Mock agent advertises session close
apps/backend/cmd/mock-agent/main.go ↗The mock agent now advertises SessionCapabilities.Close so tests can exercise the new close path.
Initialize
return acp.InitializeResponse{
ProtocolVersion: acp.ProtocolVersionNumber,
AgentCapabilities: acp.AgentCapabilities{
LoadSession: true,
McpCapabilities: acp.McpCapabilities{Sse: true},
Meta: meta,
SessionCapabilities: acp.SessionCapabilities{
Close: &acp.SessionCloseCapabilities{},
},
Meta: meta,
},
}, nil
}
CloseSession cleanup
func (a *mockAgent) CloseSession(_ context.Context, req acp.CloseSessionRequest) (acp.CloseSessionResponse, error) {
a.mu.Lock()
delete(a.sessions, req.SessionId)
delete(a.sessionConfig, req.SessionId)
delete(a.commandsEmitted, req.SessionId)
a.mu.Unlock()
_ = os.Remove(overloadedCounterPath(req.SessionId))
_ = os.Remove(transportLostCounterPath(req.SessionId))
return acp.CloseSessionResponse{}, nil
}
Tests for close guards
apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_test.go ↗New tests verify close happens when advertised, and is skipped on concurrency, missing capability, failure, or close error.
Happy path
func TestResetSessionClosesSupersededSessionWhenAdvertised(t *testing.T) {
adapter, capture := newSessionRequestCaptureAdapter(t, acpsdk.McpCapabilities{})
adapter.capabilities.SessionCapabilities.Close = &acpsdk.SessionCloseCapabilities{}
firstID, _ := adapter.NewSession(context.Background(), nil)
secondID, _ := adapter.ResetSession(context.Background(), nil)
closes := capture.recordedCloseRequests()
// closes[0].SessionId == firstID
}
Concurrency guard
func TestResetSessionSkipsCloseWhenSessionChangedConcurrently(t *testing.T) {
adapter, capture := newSessionRequestCaptureAdapter(t, acpsdk.McpCapabilities{})
adapter.capabilities.SessionCapabilities.Close = &acpsdk.SessionCloseCapabilities{}
firstID, _ := adapter.NewSession(context.Background(), nil)
secondID, _ := adapter.NewSession(context.Background(), nil)
_ = adapter.LoadSession(context.Background(), firstID, nil)
adapter.closeSupersededSession(context.Background(), adapter.acpConn, firstID, secondID)
// no close when session is current again
}
Docs: session/close in ACP protocol
apps/backend/internal/agentctl/AGENTS.md ↗Documentation now lists session/close as a supported ACP request and notes the adapter file split.
Protocol line
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume/close), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/prompt`, `session/cancel`. 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/prompt`, `session/cancel`, `session/close`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`.