#16031 · @BYK · opened Mar 4, 2026 at 5:55 PM UTC · last updated Mar 21, 2026 at 10:24 AM UTC

fix: unarchive session on touch, stop cache eviction on archive

appfix
64
+47137 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

8.0

Ease Of Review

9.0

Guidelines

6.0

Readiness

7.0

Size

7.0

Trust

5.0

Traction

0.0

Summary

This PR addresses a critical bug where archived sessions become unusable and lose all displayed data upon user interaction. It implements a two-part fix, ensuring that interacting with an archived session unarchives it and preserves its cached data in the frontend. The solution includes changes to both backend session logic and frontend event handling.

Open in GitHub

Description

Issue for this PR

Closes #16030

Type of change

  • [x] Bug fix
  • [ ] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Fixes two related bugs that occur when interacting with an archived session:

Problem: When you navigate to an archived session and send a prompt, Session.touch() publishes a session.updated SSE event with time.archived still set. The frontend event-reducer sees this and unconditionally removes the session from the store and wipes all cached data (messages, parts, diffs, todos, permissions, questions, session_status). This causes the "Unable to retrieve session" toast and makes all messages disappear.

Fix (2 parts):

  1. Backend — Session.touch() now clears time_archived (session/index.ts): When a session is touched (during prompting), time_archived is set to null. This means the published SSE event has time.archived = undefined (falsy), so the event-reducer treats it as a normal update — the session gets upserted back into the sidebar list instead of removed. This is the correct behavior: interacting with an archived session should unarchive it.

  2. Frontend — Remove cleanupSessionCaches() from archived branch (event-reducer.ts): Defense in depth. The session.updated handler still removes archived sessions from the sidebar list and decrements sessionTotal, but no longer wipes cached message/part/diff/todo/permission/question/status data. Archive is reversible — the user can navigate back at any time — and caches are harmless to keep. Memory is reclaimed naturally by store trimming. session.deleted still cleans caches since deletion is permanent.

How did you verify your code works?

  • Backend test: new test in session.test.ts verifies touch() on an archived session clears time_archived and publishes an event with archived: undefined
  • Frontend test: updated event-reducer.test.ts — the archive test now asserts that caches are preserved (session removed from list, sessionTotal decremented, but message/part/diff/todo/permission/question/status all still defined)
  • All existing tests pass (15 tests across both files)

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#16030 Archived session loses messages and becomes inaccessible when interacted with

View issue

Comments

No comments.

Changed Files

packages/app/src/components/dialog-connect-provider.tsx

+11
@@ -383,7 +383,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
body: {
type: "api",
key: apiKey,
},

packages/app/src/components/dialog-custom-provider.tsx

+11
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
body: {
type: "api",
key: result.key,
},

packages/app/src/context/global-sync/event-reducer.test.ts

+98
@@ -165,7 +165,7 @@ describe("applyDirectoryEvent", () => {
expect(store.sessionTotal).toBe(2)
})
test("cleans session caches when archived", () => {
test("removes session from list but preserves caches when archived", () => {
const message = userMessage("msg_1", "ses_1")
const [store, setStore] = createStore(
baseState({
@@ -192,13 +192,14 @@ describe("applyDirectoryEvent", () => {
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
expect(store.sessionTotal).toBe(1)
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
// Caches preserved — archive is reversible, user may navigate back
expect(store.message.ses_1).toBeDefined()
expect(store.part[message.id]).toBeDefined()
expect(store.session_diff.ses_1).toBeDefined()
expect(store.todo.ses_1).toBeDefined()
expect(store.permission.ses_1).toBeDefined()

packages/app/src/context/global-sync/event-reducer.ts

+11
@@ -126,7 +126,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

+11
@@ -265,7 +265,7 @@ function ApiMethod(props: ApiMethodProps) {
if (!value) return
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
body: {
type: "api",
key: value,
},

packages/opencode/src/session/index.ts

+11
@@ -284,7 +284,7 @@ export namespace Session {
Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_updated: now })
.set({ time_updated: now, time_archived: null })
.where(eq(SessionTable.id, sessionID))
.returning()
.get()

packages/opencode/test/session/session.test.ts

+330
@@ -72,6 +72,39 @@ describe("session.started event", () => {
})
})
describe("Session.touch", () => {
test("clears time_archived so archived sessions are unarchived on interaction", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.create({})
await Session.setArchived({ sessionID: session.id, time: Date.now() })
const archived = await Session.get(session.id)
expect(archived.time.archived).toBeDefined()
let updatedInfo: Session.Info | undefined
const unsub = Bus.subscribe(Session.Event.Updated, (event) => {
updatedInfo = event.properties.info as Session.Info
})
await Session.touch(session.id)
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
const touched = await Session.get(session.id)
expect(touched.time.archived).toBeUndefined()
expect(updatedInfo).toBeDefined()
expect(updatedInfo?.time.archived).toBeUndefined()
await Session.remove(session.id)
},
})
})
})
describe("step-finish token propagation via Bu