fix(sync): guard delete actions by default

Follow-up to #2574 and f95f1ab18, which guarded the archive path. The
delete path had the same two defects and worse consequences.

`useSessionUIStore.deleteSession`/`deleteSessions` declared an `options`
parameter and discarded it on both paths, so a caller-supplied runtime
key was a silent no-op. `SessionDialogs.tsx:416` already passes options
today and they never reach the action.

The delete path also never rechecked the runtime. Session IDs are not
unique across runtimes, so a response produced by a previous runtime
could commit `finalizeConfirmedSessionDeletion` against the runtime the
user switched to: evicting an unrelated session from the live and global
stores and calling `cleanupPersistedSessionState`, which erases queued
messages, todos, folder membership, inline-comment drafts, chat draft,
and pins. That is user data loss, not stale cache.

`cleanupPersistedSessionState` already rejects an identity whose runtime
is no longer active, but `finalizeConfirmedSessionDeletion` defeated that
check by passing the live `getRuntimeKey()` at commit time, comparing a
value with itself. It now forwards the captured key.

Adopt the default-on shape from f95f1ab18: `expectedRuntimeKey` defaults
to the active runtime in `deleteSession`, `deleteSessionInDirectory` and
the new canonical `deleteSessions` action, and is rechecked before the
request and before every reconciliation. A `404` still means "already
deleted" and commits cleanup, but only while the captured runtime is
active; after a switch it describes the wrong runtime and the action
reports failure instead of committing.

Also documents the throw contract of `patchSessionMetadata`, a
non-blocking nit raised by the review bot on #2574.
This commit is contained in:
Alexandre Reyes Martins
2026-08-02 15:13:06 +00:00
parent f95f1ab18f
commit d19ff96c02
5 changed files with 297 additions and 29 deletions
@@ -484,3 +484,42 @@ describe('archiveSessions option forwarding', () => {
expect(updateSessionCalls).toEqual([]);
});
});
describe('deleteSessions option forwarding', () => {
let originalDeleteSession;
let deleteSessionCalls;
beforeEach(() => {
deleteSessionCalls = [];
originalDeleteSession = opencodeClient.deleteSession;
opencodeClient.deleteSession = (sessionId) => {
deleteSessionCalls.push(sessionId);
return Promise.resolve(true);
};
});
afterEach(() => {
opencodeClient.deleteSession = originalDeleteSession;
});
// The store accepted an options object and dropped it on both the single and
// batch delete paths. A key that cannot match the active runtime must abort
// before any SDK call rather than deleting and erasing persisted state.
test('honors expectedRuntimeKey on the batch delete instead of discarding options', async () => {
const result = await useSessionUIStore.getState().deleteSessions(['session-x', 'session-y'], {
expectedRuntimeKey: 'runtime-that-is-not-active',
});
expect(result).toEqual({ deletedIds: [], failedIds: ['session-x', 'session-y'] });
expect(deleteSessionCalls).toEqual([]);
});
test('honors expectedRuntimeKey on the single delete instead of discarding options', async () => {
const deleted = await useSessionUIStore.getState().deleteSession('session-x', {
expectedRuntimeKey: 'runtime-that-is-not-active',
});
expect(deleted).toBe(false);
expect(deleteSessionCalls).toEqual([]);
});
});