Skip to content
Open
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
21 changes: 20 additions & 1 deletion lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ import {
} from "./commands"
import { type HostPermissionSnapshot } from "./host-permissions"
import { compressPermission, syncCompressPermissionState } from "./compress-permission"
import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state"
import {
checkSession,
deleteSessionState,
ensureSessionInitialized,
saveSessionState,
syncToolCache,
} from "./state"
import { cacheSystemPromptTokens } from "./ui/utils"

const INTERNAL_AGENT_SIGNATURES = [
Expand Down Expand Up @@ -300,6 +306,19 @@ export function createTextCompleteHandler() {

export function createEventHandler(state: SessionState, logger: Logger) {
return async (input: { event: any }) => {
// Deleting a session leaves its persisted prune state behind forever,
// and the orphan still counts towards the all-time stats (#557).
if (input.event?.type === "session.deleted") {
const deletedSessionId = input.event.properties?.info?.id
if (typeof deletedSessionId === "string" && deletedSessionId.length > 0) {
await deleteSessionState(deletedSessionId, logger)
if (state.sessionId === deletedSessionId) {
state.sessionId = null
}
}
return
}

const eventTime =
typeof input.event?.time === "number" && Number.isFinite(input.event.time)
? input.event.time
Expand Down
9 changes: 9 additions & 0 deletions lib/state/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ export async function saveManualModeSetting(
await writePersistedSessionState(sessionId, state, logger)
}

export async function deleteSessionState(sessionId: string, logger: Logger): Promise<void> {
try {
await fs.unlink(getSessionFilePath(sessionId))
logger.debug("Removed state file for deleted session", { sessionId })
} catch {
// The file may never have been written, or may already be gone.
}
}

export interface AggregatedStats {
totalTokens: number
totalTools: number
Expand Down
117 changes: 117 additions & 0 deletions tests/session-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import assert from "node:assert/strict"
import test from "node:test"
import { existsSync, readdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"

const root = mkdtempSync(join(tmpdir(), "dcp-session-delete-"))
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode")

const { Logger } = await import("../lib/logger")
const { createSessionState, saveSessionState } = await import("../lib/state")
const { createEventHandler } = await import("../lib/hooks")

const logger = new Logger(false)
const STORAGE_DIR = join(root, "data/opencode/storage/plugin/dcp")

test.after(() => rmSync(root, { recursive: true, force: true }))

// A deleted session used to leave its state file on disk forever, and the orphan
// kept contributing to the all-time stats (#557).
test("deleting a session removes its persisted state file", async () => {
const sessionId = `ses_delete_${Date.now()}`
const state = createSessionState()
state.sessionId = sessionId
state.stats.totalPruneTokens = 12_345
await saveSessionState(state, logger)

const filePath = join(STORAGE_DIR, `${sessionId}.json`)
assert.ok(existsSync(filePath), "precondition: the state file was written")

const handler = createEventHandler(state, logger)
await handler({ event: { type: "session.deleted", properties: { info: { id: sessionId } } } })

assert.equal(existsSync(filePath), false, "the orphaned state file survived")
})

test("deleting another session leaves the active one intact", async () => {
const keep = `ses_keep_${Date.now()}`
const drop = `ses_drop_${Date.now()}`
const state = createSessionState()
state.sessionId = keep
await saveSessionState(state, logger)

const dropState = createSessionState()
dropState.sessionId = drop
await saveSessionState(dropState, logger)

const handler = createEventHandler(state, logger)
await handler({ event: { type: "session.deleted", properties: { info: { id: drop } } } })

assert.ok(existsSync(join(STORAGE_DIR, `${keep}.json`)))
assert.equal(existsSync(join(STORAGE_DIR, `${drop}.json`)), false)
assert.equal(state.sessionId, keep)
})

test("deleting the active session detaches the in-memory state", async () => {
const sessionId = `ses_active_${Date.now()}`
const state = createSessionState()
state.sessionId = sessionId
await saveSessionState(state, logger)

const handler = createEventHandler(state, logger)
await handler({ event: { type: "session.deleted", properties: { info: { id: sessionId } } } })

assert.equal(state.sessionId, null)
})

test("a delete event for an unknown session is harmless", async () => {
const state = createSessionState()
state.sessionId = "ses_current"
const handler = createEventHandler(state, logger)

await handler({ event: { type: "session.deleted", properties: {} } })
await handler({ event: { type: "session.deleted" } })
await handler({ event: { type: "session.deleted", properties: { info: { id: "" } } } })

assert.equal(state.sessionId, "ses_current")
})

test("the delete branch does not swallow the compression timing branch", async () => {
// message.part.updated must still be routed to the timing handler.
const state = createSessionState()
state.sessionId = "ses_timing"
const handler = createEventHandler(state, logger)

await handler({
event: {
type: "message.part.updated",
time: 1_000,
properties: {
part: {
type: "tool",
tool: "compress",
callID: "call_x",
messageID: "msg_x",
state: { status: "pending" },
},
},
},
})

assert.equal(state.compressionTiming.startsByCallId.size, 1)
})

test("unrelated events leave the storage directory alone", async () => {
const state = createSessionState()
const handler = createEventHandler(state, logger)
const before = readdirSync(STORAGE_DIR).sort()

await handler({ event: { type: "session.updated", properties: { info: { id: "ses_x" } } } })
await handler({ event: { type: "storage.write" } })

assert.deepEqual(readdirSync(STORAGE_DIR).sort(), before)
})