mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
* fix(ai): persist AI sessions per record in IndexedDB instead of a 2MB localStorage blob Closes #4123. The legacy store serialized every session into one localStorage JSON key under a 2MB byte budget. Each write re-serialized the whole store, and the degradation ladder dropped whole oldest sessions when the budget was exceeded — silently destroying capped chats (~1MB each) on every write. - add aiSessionIdbStorage: db netcatty_ai_sessions, one record per session (keyPath id) plus a meta store (schemaVersion, lastWriteAt, mirrored active-session map); BroadcastChannel replaces the localStorage storage event for cross-window sync - one-time boot migration from netcatty_ai_sessions_v1; the legacy key stays untouched for one release as a read-only fallback - writeSessionsForStorage now diffs against the last persisted set and writes only changed records through a serialized queue; the 2MB budget and its degradation ladder are removed (the global re-serialization cost they defended no longer exists) - retention becomes a pure session-count cap (raised 50 -> 1000); the per-session 200-message cap is unchanged - when the cap evicts sessions, a pre-eviction backup generation is written before the records are dropped, closing the #4018 mirror blind spot; explicit deletions and cloud-authoritative removals do not trigger it - boot restore chain: IndexedDB -> legacy localStorage (migration) -> #4018 disk backup generations; a corrupt/unavailable IndexedDB falls back to legacy localStorage writes - tests: IDB roundtrip/migration/active-map mirror via a minimal fake indexedDB, per-record put/delete diffing, count-cap eviction with pre-eviction generation, explicit-delete suppression, synced-history no-prune, corrupt-IDB fallback; byte-ladder tests removed with the ladder * test(ai): update fork/undo reload tests to the IndexedDB persistence contract Issue #4123: session persistence moved to IndexedDB per-record writes; the persisted set no longer lands in the legacy localStorage key (read-only fallback for one release) and the retention cap is a session count of 1000 instead of 50. - fork/undo reload tests read the persisted set via getPersistedAiSessionsSnapshot() instead of parsing localStorage - cap-boundary assertions (49/50 newer chats) become preservation assertions (newerCount + 2 records survive; the 1000-cap boundary is covered by aiStateSnapshots.test.ts) - the reload step seeds the read-only legacy store with the persisted set, the stand-in for what the migration persists on a profile without IndexedDB * feat(ai): make the per-session message cap user-configurable * fix(ai): sequence session sync after boot hydration Cloud-sync collection (collectAiSessionsForSync) could run before the IndexedDB boot hydration settled and writeSessionsForStorage() the stale legacy localStorage list over newer per-record IndexedDB data. The collection now skips the sync round until the boot load settles; the next auto-sync picks up the hydrated store. The hydration signal lives in aiStateSnapshots (isAiSessionsHydrationComplete / markAiSessionsHydrationComplete) and is shared with useAIState's boot effect and the cross-window refresh handler. * fix(ai): make the legacy session migration one-shot via a persisted marker The migration keyed on 'IndexedDB empty + legacy populated'. Because the legacy key is intentionally kept for one release as a read-only fallback, any boot whose store was empty re-ran the migration and resurrected chats the user had deleted (P1②). A migrationCompleted marker is now persisted in the meta store after the migration data is durable. Once present, the legacy key is never consulted again: an empty store is authoritative. The marker also records a store that was already populated by a pre-marker build, and an uninitialized empty store, so 'intentionally empty' is distinguishable from 'uninitialized'. * fix(ai): harden boot persistence failure paths Four review findings around the boot load's failure modes: - P2①: when IndexedDB fails at boot and sessions come from the legacy store, the diff baseline was seeded even though nothing was persisted, so a later edit diff-wrote only the edited record and the rest were lost after recovery. The legacy set is now persisted best-effort (one re-open retry) before seeding; if that fails the baseline stays unset so the next edit rewrites every record. - P2②: the active-pointer validation effect ran against the initially-empty in-memory session array and nulled every pointer before the async load finished. It now waits for the hydration signal. - P2③: when IndexedDB stayed unopenable and the legacy store was missing/corrupt, the unconditional recheck threw into the swallowing outer catch and the #4018 disk backup was never applied. The recheck failure is now contained (one retry already happens inside open()), the newest backup generation is applied as the recovery source, and the error is surfaced via console.warn. - P2④: a cross-window empty getAll (another window deleted the last chat) hit an early return, kept the deleted session alive locally and resurrected it on the next edit. After hydration an empty store is adopted; the early-return only covers the pre-hydration window. Scenario regression tests live in useAIState.bootHydration.test.ts; fakeIndexedDb gains failOpenTimes for counted open failures. * fix(ai): gate session maintenance and migrate legacy overflow history * fix(ai): skip backup restore of empty IDB and resume failed overflow migration * ci: retrigger lint-and-test after runner shutdown The previous Actions runner received a shutdown signal during plugin-contract verification; lint had already passed and unit tests never started. --------- Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com> Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
142 lines
7.3 KiB
TypeScript
142 lines
7.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { JSDOM } from 'jsdom';
|
|
|
|
import type { AISession } from '../../infrastructure/ai/types.ts';
|
|
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
|
import { useAIState } from './useAIState.ts';
|
|
import { useAISessionsStore } from './aiSessionsStore.ts';
|
|
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
|
|
|
const SOURCE_SESSION: AISession = {
|
|
id: 'chat-source',
|
|
title: 'Fork source',
|
|
agentId: 'catty',
|
|
scope: { type: 'terminal', targetId: 'terminal-a' },
|
|
messages: [
|
|
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
|
|
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
|
|
{
|
|
id: 'user-2',
|
|
role: 'user',
|
|
content: 'bad last prompt',
|
|
timestamp: 3,
|
|
attachments: [
|
|
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
|
|
],
|
|
},
|
|
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
|
|
],
|
|
createdAt: 1,
|
|
updatedAt: 4,
|
|
};
|
|
|
|
type HookCapture = {
|
|
ai: ReturnType<typeof useAIState> | null;
|
|
sessions: AISession[];
|
|
activeSessionIdMap: Record<string, string | null>;
|
|
};
|
|
|
|
function ForkHarness({ capture }: { capture: HookCapture }) {
|
|
capture.ai = useAIState();
|
|
const store = useAISessionsStore();
|
|
capture.sessions = store.sessions as AISession[];
|
|
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
|
return null;
|
|
}
|
|
|
|
async function setupAiState(sessions: AISession[]) {
|
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
|
url: 'http://localhost',
|
|
});
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
|
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
|
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
|
|
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
|
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
|
|
|
setLatestAISessionsSnapshot(sessions);
|
|
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
|
|
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
|
|
await act(async () => {
|
|
root.render(React.createElement(ForkHarness, { capture }));
|
|
});
|
|
return { dom, container, root, capture };
|
|
}
|
|
|
|
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
|
|
const runtime = getAgentRuntime();
|
|
const outputStore = runtime.getToolOutputStore('chat-source');
|
|
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
|
|
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
|
|
{ sessionId: 'terminal-a', command: 'running-command' },
|
|
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
|
|
const source = structuredClone(SOURCE_SESSION);
|
|
source.externalSessionId = 'original-provider-thread';
|
|
source.messages[1].content = `Preview handleId=${handle.id}`;
|
|
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
|
|
const { dom, container, root, capture } = await setupAiState([source]);
|
|
let branch: AISession | null = null;
|
|
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
|
|
assert.ok(branch);
|
|
assert.notEqual(branch!.id, source.id);
|
|
assert.equal(branch!.externalSessionId, undefined);
|
|
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
|
|
assert.match(branch!.messages[1].content, /Preview/);
|
|
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
|
|
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
|
|
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
|
|
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
|
|
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
|
|
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
|
|
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
|
|
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
|
|
runtime.getSessionStateStore().clear(source.id);
|
|
outputStore.prune(source.id);
|
|
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
|
});
|
|
|
|
test('fork rejects an incomplete boundary without adding a chat', async () => {
|
|
const source = structuredClone(SOURCE_SESSION);
|
|
source.messages[1].executionStatus = 'failed';
|
|
const { dom, container, root, capture } = await setupAiState([source]);
|
|
let result: unknown;
|
|
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
|
|
assert.equal(result, null);
|
|
assert.equal(capture.sessions.length, 1);
|
|
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
|
});
|
|
|
|
for (const newerCount of [49, 50]) {
|
|
test(`fork preserves the original on reload with ${newerCount} newer chats`, async () => {
|
|
const newer = Array.from({ length: newerCount }, (_, index) => ({ ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index }));
|
|
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
|
|
let branch: AISession | null = null;
|
|
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
|
assert.ok(branch);
|
|
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
|
// nothing is evicted at these sizes) — not in a 50-capped localStorage
|
|
// blob.
|
|
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
|
assert.equal(saved.length, newerCount + 2);
|
|
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
|
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
|
await act(async () => root.unmount()); container.remove();
|
|
// Reload from the read-only legacy store (the stand-in for what the
|
|
// migration persists on a profile without IndexedDB).
|
|
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
|
dom.window.close();
|
|
const reloaded = await setupAiState(saved);
|
|
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
|
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
|
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
|
|
});
|
|
}
|