mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 04:22:38 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222de09600 |
No files matched your search
@@ -717,3 +717,103 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function freezeHistory<T>(value: T): T {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(freezeHistory);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
|
||||
return Array.from({ length: count }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
count - index,
|
||||
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
|
||||
id: `message-${index}-${messageIndex}`,
|
||||
role: messageIndex % 2 ? 'assistant' : 'user',
|
||||
content: 'ordinary text '.repeat(8),
|
||||
timestamp: messageIndex,
|
||||
...(messageIndex % 2 ? {
|
||||
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: 'reasoning',
|
||||
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
|
||||
}],
|
||||
},
|
||||
} : {}),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
|
||||
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
|
||||
const expected = sessions.slice(0, 50);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
|
||||
const result = serializeSessionsForStorage(sessions);
|
||||
|
||||
assert.equal(stringify.mock.callCount(), expected.length);
|
||||
assert.equal(result.json, expectedJson);
|
||||
assert.deepEqual(result.sessions, expected);
|
||||
assert.equal(result.sessions[0], sessions[0]);
|
||||
});
|
||||
}
|
||||
|
||||
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = makePersistenceHistory(1);
|
||||
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
|
||||
freezeHistory(sessions);
|
||||
const fullJson = JSON.stringify(sessions);
|
||||
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
|
||||
for (const budget of [fullJson.length, fullJson.length + 1]) {
|
||||
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
|
||||
}
|
||||
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
|
||||
assert.ok(overflow.json.length < fullJson.length);
|
||||
assert.equal(JSON.stringify(sessions), fullJson);
|
||||
});
|
||||
|
||||
test('fit-first empty history preserves output even below the minimum array budget', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
for (const budget of [0, 1, 2, 3]) {
|
||||
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(51, 201));
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const write = t.mock.method(localStorageAdapter, 'write', () => false);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(write.mock.callCount(), 1);
|
||||
assert.equal(write.mock.calls[0].arguments[1], sessions);
|
||||
assert.equal(writeString.mock.callCount(), 0);
|
||||
|
||||
read.mock.mockImplementation(() => ({ enabled: false }));
|
||||
const error = new Error('non-quota storage failure');
|
||||
writeString.mock.mockImplementation(() => { throw error; });
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(20));
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
const result = serializeSessionsForStorage(sessions, budget);
|
||||
// Same work as the original ladder: no second full-session serialization
|
||||
// after the fit check, even when a last-resort degraded chat is needed.
|
||||
assert.equal(stringify.mock.callCount(), expectedCalls);
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
@@ -363,12 +363,30 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
@@ -379,7 +397,6 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
Reference in new issue
Block a user