diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts index e0af3d7474..05f8429e37 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts @@ -1,5 +1,6 @@ import type { AgentToolContext, HubEventEnvelope } from "@cline/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionCompactionState } from "../../session/models/session-compaction"; import { SessionSource } from "../../types/common"; const commandMock = vi.hoisted(() => vi.fn()); @@ -1410,6 +1411,69 @@ describe("HubRuntimeHost", () => { }); }); + it("records rejected compaction state updates as handled errors", async () => { + const telemetry = { capture: vi.fn() }; + const state = createSessionCompactionState({ + sourceMessages: [{ role: "user", content: "source" }], + compactedMessages: [{ role: "user", content: "summary" }], + conversationId: "sess-1", + }); + commandMock.mockResolvedValue({ + ok: false, + error: { + code: "session_wrong_client", + message: "Session sess-1 is owned by other-client", + }, + }); + + const { HubRuntimeHost } = await import("./hub-runtime-host"); + const host = new HubRuntimeHost({ + url: "ws://127.0.0.1:25463/hub", + telemetry: telemetry as never, + }); + + await expect( + host.updateSessionCompactionState(" sess-1 ", state), + ).resolves.toEqual({ updated: false }); + expect(telemetry.capture).toHaveBeenCalledWith({ + event: "sdk.error", + properties: expect.objectContaining({ + component: "core", + operation: "hub.runtime_host.update_session_compaction_state", + severity: "warn", + handled: true, + command: "session.compaction.update", + sessionId: "sess-1", + errorCode: "session_wrong_client", + error_message: "Session sess-1 is owned by other-client", + }), + }); + }); + + it("treats stale compaction state updates as non-error no-ops", async () => { + const telemetry = { capture: vi.fn() }; + const state = createSessionCompactionState({ + sourceMessages: [{ role: "user", content: "source" }], + compactedMessages: [{ role: "user", content: "summary" }], + conversationId: "sess-1", + }); + commandMock.mockResolvedValue({ + ok: true, + payload: { updated: false }, + }); + + const { HubRuntimeHost } = await import("./hub-runtime-host"); + const host = new HubRuntimeHost({ + url: "ws://127.0.0.1:25463/hub", + telemetry: telemetry as never, + }); + + await expect( + host.updateSessionCompactionState("sess-1", state), + ).resolves.toEqual({ updated: false }); + expect(telemetry.capture).not.toHaveBeenCalled(); + }); + it("throws when the hub rejects settings list", async () => { commandMock.mockResolvedValue({ ok: false, diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts index b9df6bbbb8..5c51f3b224 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts @@ -1301,6 +1301,22 @@ export class HubRuntimeHost implements RuntimeHost { { sessionId: target, state }, target, ); + if (!reply.ok) { + captureSdkError(this.telemetry, { + component: "core", + operation: "hub.runtime_host.update_session_compaction_state", + error: new Error( + hubReplyErrorMessage(reply, "session.compaction.update"), + ), + severity: "warn", + handled: true, + context: { + command: "session.compaction.update", + sessionId: target, + errorCode: reply.error?.code, + }, + }); + } return { updated: reply.ok && reply.payload?.updated === true, }; diff --git a/sdk/packages/core/src/hub/server/boundary.test.ts b/sdk/packages/core/src/hub/server/boundary.test.ts index fcf95c3ff4..512631bb10 100644 --- a/sdk/packages/core/src/hub/server/boundary.test.ts +++ b/sdk/packages/core/src/hub/server/boundary.test.ts @@ -923,6 +923,47 @@ describe("HubServerTransport boundaries", () => { expect(updateSessionCompactionState).not.toHaveBeenCalled(); }); + it("allows a creator to claim ownerless compaction sidecar ownership", async () => { + const state = createSessionCompactionState({ + sourceMessages: [{ role: "user", content: "source" }], + compactedMessages: [{ role: "user", content: "summary" }], + conversationId: "session-1", + }); + const readSessionCompactionState = vi.fn().mockResolvedValue(state); + const transport = createTransport({ + sessionHost: { readSessionCompactionState }, + }); + const ctx = getContext(transport); + ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant"); + + expect( + ctx.sessionState.get("session-1")?.createdByClientId, + ).toBeUndefined(); + + ensureSessionState(ctx, "session-1", "owner-client", "creator"); + + expect(ctx.sessionState.get("session-1")?.createdByClientId).toBe( + "owner-client", + ); + expect( + ctx.sessionState.get("session-1")?.participants.has("viewer-client"), + ).toBe(true); + + const getReply = await transport.handleCommand({ + version: "v1", + requestId: "req-compact-get", + command: "session.compaction.get", + clientId: "owner-client", + sessionId: "session-1", + }); + + expect(getReply).toMatchObject({ + ok: true, + payload: { state }, + }); + expect(readSessionCompactionState).toHaveBeenCalledWith("session-1"); + }); + it("clears compaction sidecar ownership when the owner detaches", async () => { const readSessionCompactionState = vi.fn(); const transport = createTransport({ @@ -1125,7 +1166,7 @@ describe("HubServerTransport boundaries", () => { }); expect(reply).toMatchObject({ - ok: false, + ok: true, payload: { updated: false }, }); expect(events).not.toEqual( diff --git a/sdk/packages/core/src/hub/server/handlers/context.ts b/sdk/packages/core/src/hub/server/handlers/context.ts index ddc34bc6d6..69ee577368 100644 --- a/sdk/packages/core/src/hub/server/handlers/context.ts +++ b/sdk/packages/core/src/hub/server/handlers/context.ts @@ -183,6 +183,9 @@ export function ensureSessionState( if (options.interactive !== undefined) { existing.interactive = options.interactive; } + if (role === "creator" && !existing.createdByClientId) { + existing.createdByClientId = clientId; + } if (!existing.participants.has(clientId)) { existing.participants.set(clientId, { clientId, diff --git a/sdk/packages/core/src/hub/server/handlers/session-handlers.ts b/sdk/packages/core/src/hub/server/handlers/session-handlers.ts index 57aa93b710..b2dbe30c9c 100644 --- a/sdk/packages/core/src/hub/server/handlers/session-handlers.ts +++ b/sdk/packages/core/src/hub/server/handlers/session-handlers.ts @@ -32,13 +32,6 @@ import { const CAPABILITY_OWNER_METADATA_KEY = "hubCapabilityOwnerClientId"; -function setCapabilityOwner( - metadata: Record, - clientId: string, -): void { - metadata[CAPABILITY_OWNER_METADATA_KEY] = clientId; -} - function getCapabilityOwnerClientId( ctx: HubTransportContext, sessionId: string, @@ -168,7 +161,6 @@ export async function handleSessionCreate( cwd: typeof payload.cwd === "string" ? payload.cwd : undefined, contributionCount: clientContributions.length, }); - setCapabilityOwner(metadata as Record, clientId); const requestedSessionId = typeof sessionConfig?.sessionId === "string" ? sessionConfig.sessionId.trim() @@ -426,7 +418,6 @@ export async function handleSessionRestore( const clientContributions = parseHubClientContributions( runtimeOptions.clientContributions, ); - setCapabilityOwner(metadata as Record, clientId); const requestedSessionId = typeof sessionConfig?.sessionId === "string" ? sessionConfig.sessionId.trim() @@ -896,7 +887,7 @@ export async function handleSessionCompactionUpdate( return { version: envelope.version, requestId: envelope.requestId, - ok: updated.updated, + ok: true, payload: { updated: updated.updated, session: updatedSession ?? session, diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts index 6f4742200a..5059a9eb4a 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts @@ -14,6 +14,7 @@ import type { import { setClineDir, setHomeDir } from "@cline/shared/storage"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TelemetryService } from "../../services/telemetry/TelemetryService"; +import { createSessionCompactionState } from "../../session/models/session-compaction"; import type { SessionManifest } from "../../session/models/session-manifest"; import { SessionSource } from "../../types/common"; import type { CoreSessionConfig } from "../../types/config"; @@ -4336,6 +4337,83 @@ describe("LocalRuntimeHost", () => { ); }); + it("does not project compaction state when compaction is disabled", async () => { + const sessionId = "sess-compaction-disabled"; + const manifest = createManifest(sessionId); + const initialMessages: MessageWithMetadata[] = [ + { role: "user", content: "canonical source" }, + ]; + const initialCompactionState = createSessionCompactionState({ + sourceMessages: initialMessages, + compactedMessages: [{ role: "user", content: "projected summary" }], + conversationId: sessionId, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const sessionService = { + ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"), + createRootSessionWithArtifacts: vi.fn().mockResolvedValue({ + manifestPath: "/tmp/manifest-compaction-disabled.json", + messagesPath: "/tmp/messages-compaction-disabled.json", + manifest, + }), + persistSessionMessages: vi.fn(), + persistSessionCompactionState: vi.fn(), + updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }), + writeSessionManifest: vi.fn(), + listSessions: vi.fn().mockResolvedValue([]), + deleteSession: vi.fn().mockResolvedValue({ deleted: true }), + }; + const run = vi.fn().mockResolvedValue(createResult()); + const createAgent = vi.fn().mockReturnValue({ + run, + continue: vi.fn(), + abort: vi.fn(), + subscribeEvents: vi.fn().mockReturnValue(() => {}), + canStartRun: vi.fn().mockReturnValue(true), + getAgentId: vi.fn().mockReturnValue("agent-root-1"), + getConversationId: vi.fn().mockReturnValue(sessionId), + restore: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockReturnValue(initialMessages), + messages: initialMessages, + }); + const manager = new RuntimeHostUnderTest({ + distinctId, + sessionService: sessionService as never, + runtimeBuilder: { + build: vi.fn().mockReturnValue({ + tools: [], + shutdown: vi.fn(), + }), + }, + createAgent: createAgent as never, + }); + + await manager.startSession( + normalizeStartInput({ + config: createConfig({ + sessionId, + compaction: { + enabled: false, + strategy: "basic", + }, + }), + initialMessages, + initialCompactionState, + interactive: true, + }), + ); + + const prepareTurn = createAgent.mock.calls[0]?.[0]?.prepareTurn; + expect(prepareTurn).toBeUndefined(); + expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled(); + + await expect( + manager.updateSessionCompactionState(sessionId, initialCompactionState), + ).resolves.toEqual({ updated: true }); + expect(createAgent.mock.calls[0]?.[0]?.prepareTurn).toBeUndefined(); + }); + it("formats prompt in core and merges explicit + mention user files", async () => { const tempCwd = mkdtempSync(join(tmpdir(), "core-session-format-")); try { diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index d47131debf..8a4f6011b3 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -485,77 +485,79 @@ export class LocalRuntimeHost implements RuntimeHost { const explicitInitialCompactionState = startInput.initialCompactionState; let activeSessionRef: ActiveSession | undefined; const compact = createContextCompactionPrepareTurn(configWithProvider); - const initialCompactionState = - explicitInitialCompactionState ?? - (compact ? resumedCompactionState : undefined); - const prepareTurn = createCompactionStateAwarePrepareTurn({ - compact, - getState: () => activeSessionRef?.compactionState, - saveState: async (state) => { - const activeSession = activeSessionRef; - if (!activeSession) return; - const stateForSession = { - ...state, - conversation_id: activeSession.sessionId, - }; - try { - const result = await this.persistActiveSessionCompactionState( - activeSession, - stateForSession, - ); - if (!result.updated) { - configWithProvider.logger?.debug?.( - "Skipped stale session compaction state", - { - sessionId: activeSession.sessionId, - sourceMessageCount: stateForSession.source_message_count, - }, - ); - } - } catch (error) { - configWithProvider.logger?.error?.( - "Failed to persist session compaction state", - { sessionId: activeSession.sessionId, error }, - ); - captureSdkError(configWithProvider.telemetry, { - component: "core", - operation: "session.persist_compaction_state", - severity: "warn", - handled: true, - error, - context: { - sessionId: activeSession.sessionId, - providerId: configWithProvider.providerId, - modelId: configWithProvider.modelId, - }, - }); - } - }, - clearState: async () => { - const activeSession = activeSessionRef; - if (!activeSession?.compactionState) return; - try { - await this.clearActiveSessionCompactionState(activeSession); - } catch (error) { - configWithProvider.logger?.error?.( - "Failed to delete stale session compaction state", - { sessionId: activeSession.sessionId, error }, - ); - captureSdkError(configWithProvider.telemetry, { - component: "core", - operation: "session.delete_compaction_state", - severity: "warn", - handled: true, - error, - context: { - sessionId: activeSession.sessionId, - providerId: configWithProvider.providerId, - modelId: configWithProvider.modelId, - }, - }); - } - }, - }); + const initialCompactionState = compact + ? (explicitInitialCompactionState ?? resumedCompactionState) + : undefined; + const prepareTurn = compact + ? createCompactionStateAwarePrepareTurn({ + compact, + getState: () => activeSessionRef?.compactionState, + saveState: async (state) => { + const activeSession = activeSessionRef; + if (!activeSession) return; + const stateForSession = { + ...state, + conversation_id: activeSession.sessionId, + }; + try { + const result = await this.persistActiveSessionCompactionState( + activeSession, + stateForSession, + ); + if (!result.updated) { + configWithProvider.logger?.debug?.( + "Skipped stale session compaction state", + { + sessionId: activeSession.sessionId, + sourceMessageCount: stateForSession.source_message_count, + }, + ); + } + } catch (error) { + configWithProvider.logger?.error?.( + "Failed to persist session compaction state", + { sessionId: activeSession.sessionId, error }, + ); + captureSdkError(configWithProvider.telemetry, { + component: "core", + operation: "session.persist_compaction_state", + severity: "warn", + handled: true, + error, + context: { + sessionId: activeSession.sessionId, + providerId: configWithProvider.providerId, + modelId: configWithProvider.modelId, + }, + }); + } + }, + clearState: async () => { + const activeSession = activeSessionRef; + if (!activeSession?.compactionState) return; + try { + await this.clearActiveSessionCompactionState(activeSession); + } catch (error) { + configWithProvider.logger?.error?.( + "Failed to delete stale session compaction state", + { sessionId: activeSession.sessionId, error }, + ); + captureSdkError(configWithProvider.telemetry, { + component: "core", + operation: "session.delete_compaction_state", + severity: "warn", + handled: true, + error, + context: { + sessionId: activeSession.sessionId, + providerId: configWithProvider.providerId, + modelId: configWithProvider.modelId, + }, + }); + } + }, + }) + : undefined; const agentConfig = { sessionId, diff --git a/sdk/packages/core/src/session/services/persistence-service.test.ts b/sdk/packages/core/src/session/services/persistence-service.test.ts index 0989896333..bf5d290e10 100644 --- a/sdk/packages/core/src/session/services/persistence-service.test.ts +++ b/sdk/packages/core/src/session/services/persistence-service.test.ts @@ -700,4 +700,55 @@ describe("UnifiedSessionPersistenceService", () => { expect(existsSync(join(sessionsDir, sessionId))).toBe(false); }, ); + + sqliteIt( + "deletes a session when compaction sidecar cleanup fails", + async () => { + const dbDir = mkdtempSync(join(tmpdir(), "delete-sidecar-fail-db-")); + const sessionsDir = mkdtempSync( + join(tmpdir(), "delete-sidecar-fail-sessions-"), + ); + tempDirs.push(dbDir, sessionsDir); + + const store = new SqliteSessionStore({ sessionsDir: dbDir }); + stores.push(store); + const service = new CoreSessionService(store, { + sessionArtifactsDir: sessionsDir, + }); + const sessionId = "sidecar-delete-fail-session"; + await service.createRootSessionWithArtifacts({ + sessionId, + source: SessionSource.CLI, + pid: process.pid, + interactive: false, + provider: "anthropic", + model: "claude-sonnet-4-6", + cwd: "/tmp/project", + workspaceRoot: "/tmp/project", + enableTools: true, + enableSpawn: false, + enableTeams: false, + prompt: "delete me", + startedAt: "2026-04-10T19:00:00.000Z", + }); + const manifestStore = ( + service as unknown as { + manifestStore: { + deleteSessionCompactionState: (sessionId: string) => Promise; + }; + } + ).manifestStore; + const deleteSidecar = vi + .spyOn(manifestStore, "deleteSessionCompactionState") + .mockRejectedValue(new Error("sidecar busy")); + + const result = await service.deleteSession(sessionId); + + expect(result).toEqual({ deleted: true }); + await expect(service.listSessions(10)).resolves.not.toEqual( + expect.arrayContaining([expect.objectContaining({ sessionId })]), + ); + expect(deleteSidecar).toHaveBeenCalledWith(sessionId); + }, + ); }); diff --git a/sdk/packages/core/src/session/services/persistence-service.ts b/sdk/packages/core/src/session/services/persistence-service.ts index 6390ba801a..5b00510f24 100644 --- a/sdk/packages/core/src/session/services/persistence-service.ts +++ b/sdk/packages/core/src/session/services/persistence-service.ts @@ -568,9 +568,7 @@ export class UnifiedSessionPersistenceService { children.map(async (child) => { await deleteCheckpointRefs(child.cwd, child.sessionId); unlinkIfExists(child.messagesPath); - await this.manifestStore.deleteSessionCompactionState( - child.sessionId, - ); + await this.deleteSessionCompactionStateIfExists(child.sessionId); unlinkIfExists( this.manifestStore.artifacts.sessionManifestPath( child.sessionId, @@ -585,7 +583,7 @@ export class UnifiedSessionPersistenceService { await deleteCheckpointRefs(row.cwd, id); unlinkIfExists(row.messagesPath); - await this.manifestStore.deleteSessionCompactionState(id); + await this.deleteSessionCompactionStateIfExists(id); unlinkIfExists(this.manifestStore.artifacts.sessionManifestPath(id, false)); if (row.isSubagent) { this.manifestStore.artifacts.removeSessionDirIfEmpty(id); @@ -604,4 +602,12 @@ export class UnifiedSessionPersistenceService { } return { deleted: true }; } + + private async deleteSessionCompactionStateIfExists( + sessionId: string, + ): Promise { + try { + await this.manifestStore.deleteSessionCompactionState(sessionId); + } catch {} + } }