Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
514 lines
17 KiB
TypeScript
514 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { AISession } from '../../infrastructure/ai/types';
|
|
import {
|
|
cleanupClosedTerminalSessions,
|
|
cleanupDeletedAIChatSessions,
|
|
cleanupSdkAgentSessions,
|
|
} from './aiStateSnapshots';
|
|
|
|
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
|
const sdkCleanups: string[] = [];
|
|
const outputCleanups: string[] = [];
|
|
const terminalOutputCleanups: string[] = [];
|
|
const previousWindow = globalThis.window;
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: {
|
|
netcatty: {
|
|
aiSdkAgentCleanup: async (chatSessionId: string) => {
|
|
sdkCleanups.push(chatSessionId);
|
|
return { ok: true };
|
|
},
|
|
deleteChatToolOutputsTemp: async (chatSessionId: string) => {
|
|
outputCleanups.push(chatSessionId);
|
|
return { deletedCount: 1 };
|
|
},
|
|
deleteTerminalToolOutputsEverywhereTemp: async (terminalSessionId: string) => {
|
|
terminalOutputCleanups.push(terminalSessionId);
|
|
return { deletedCount: 1 };
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
cleanupSdkAgentSessions(['history-kept']);
|
|
cleanupDeletedAIChatSessions(['history-deleted']);
|
|
cleanupClosedTerminalSessions(['terminal-closed', 'terminal-closed']);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(sdkCleanups, ['history-kept', 'history-deleted']);
|
|
assert.deepEqual(outputCleanups, ['history-deleted']);
|
|
assert.deepEqual(terminalOutputCleanups, ['terminal-closed']);
|
|
} finally {
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: previousWindow,
|
|
});
|
|
}
|
|
});
|
|
|
|
function makeSession(id: string, updatedAt: number, messages: unknown[]): AISession {
|
|
return {
|
|
id,
|
|
title: id,
|
|
agentId: 'agent',
|
|
scope: { type: 'terminal', targetId: 't' },
|
|
messages: messages as never,
|
|
createdAt: updatedAt,
|
|
updatedAt,
|
|
} as never;
|
|
}
|
|
|
|
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
|
const messages = () => [{
|
|
id: 'm',
|
|
role: 'assistant' as const,
|
|
content: 'hello',
|
|
timestamp: 0,
|
|
providerContinuation: {
|
|
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
|
},
|
|
}];
|
|
const sessions = [
|
|
makeSession('newest', 3, messages()),
|
|
makeSession('older', 2, messages()),
|
|
makeSession('oldest', 1, messages()),
|
|
];
|
|
|
|
const hasCiphertext = (result: { sessions: AISession[] }) =>
|
|
result.sessions.some(s => s.messages.some(m =>
|
|
m.providerContinuation?.reasoningParts?.some(p => typeof p.providerOptions?.openai?.reasoningEncryptedContent === 'string')));
|
|
|
|
// Removing replay-only ciphertext from the oldest session fits the budget,
|
|
// so every visible chat remains available after restart.
|
|
const withOldestCiphertextStripped = serializeSessionsForStorage(sessions, 600 * 1024);
|
|
assert.deepEqual(
|
|
withOldestCiphertextStripped.sessions.map(s => s.id),
|
|
['newest', 'older', 'oldest'],
|
|
);
|
|
assert.equal(hasCiphertext(withOldestCiphertextStripped), true);
|
|
assert.ok(withOldestCiphertextStripped.json.length <= 600 * 1024);
|
|
|
|
// Tight budget that even a single session exceeds: ciphertext stripped but
|
|
// the visible conversation content survives.
|
|
const withCiphertextStripped = serializeSessionsForStorage(sessions, 220 * 1024);
|
|
assert.ok(withCiphertextStripped.json.length <= 220 * 1024);
|
|
assert.equal(hasCiphertext(withCiphertextStripped), false);
|
|
assert.deepEqual(withCiphertextStripped.sessions.map(s => s.id), ['newest', 'older', 'oldest']);
|
|
assert.equal(withCiphertextStripped.sessions[0].messages[0].content, 'hello');
|
|
});
|
|
|
|
test('serializeSessionsForStorage keeps new ciphertext when an oversized old chat must be dropped', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
|
|
const newest = makeSession('newest', 2, [{
|
|
id: 'new-message',
|
|
role: 'assistant',
|
|
content: 'new visible chat',
|
|
timestamp: 2,
|
|
providerContinuation: {
|
|
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
|
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
|
},
|
|
}]);
|
|
const oversizedOld = makeSession('oldest', 1, [{
|
|
id: 'old-message',
|
|
role: 'user',
|
|
content: 'x'.repeat(300 * 1024),
|
|
timestamp: 1,
|
|
}]);
|
|
|
|
const result = serializeSessionsForStorage([oversizedOld, newest], 200 * 1024);
|
|
|
|
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
|
|
assert.equal(
|
|
result.sessions[0].messages[0].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
ciphertext,
|
|
);
|
|
assert.ok(result.json.length <= 200 * 1024);
|
|
});
|
|
|
|
test('serializeSessionsForStorage prioritizes a usable newest chat over older visible history', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
|
|
const newest = makeSession('newest', 2, [{
|
|
id: 'new-message',
|
|
role: 'assistant',
|
|
content: 'new visible chat',
|
|
timestamp: 2,
|
|
providerContinuation: {
|
|
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
|
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
|
},
|
|
}]);
|
|
const olderVisible = makeSession('oldest', 1, [{
|
|
id: 'old-message',
|
|
role: 'user',
|
|
content: 'x'.repeat(100 * 1024),
|
|
timestamp: 1,
|
|
}]);
|
|
|
|
const result = serializeSessionsForStorage([olderVisible, newest], 150 * 1024);
|
|
|
|
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
|
|
assert.equal(
|
|
result.sessions[0].messages[0].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
ciphertext,
|
|
);
|
|
assert.ok(result.json.length <= 150 * 1024);
|
|
});
|
|
|
|
test('serializeSessionsForStorage keeps the newest replayable turn in one oversized chat', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
|
|
const reasoning = (itemId: string) => ({
|
|
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
|
reasoningParts: [{
|
|
text: '',
|
|
providerOptions: { openai: { itemId, reasoningEncryptedContent: ciphertext } },
|
|
}],
|
|
});
|
|
const session = makeSession('active', 1, [
|
|
{
|
|
id: 'assistant-old',
|
|
role: 'assistant',
|
|
content: 'old turn',
|
|
timestamp: 1,
|
|
providerContinuation: reasoning('rs_old'),
|
|
toolCalls: [{ id: 'call-old', name: 'terminal_execute', arguments: { command: 'pwd' } }],
|
|
},
|
|
{
|
|
id: 'tool-old',
|
|
role: 'tool',
|
|
content: '',
|
|
timestamp: 2,
|
|
toolResults: [{ toolCallId: 'call-old', content: '/tmp' }],
|
|
},
|
|
{
|
|
id: 'assistant-new',
|
|
role: 'assistant',
|
|
content: 'new turn',
|
|
timestamp: 3,
|
|
providerContinuation: reasoning('rs_new'),
|
|
toolCalls: [{ id: 'call-new', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
|
},
|
|
{
|
|
id: 'tool-new',
|
|
role: 'tool',
|
|
content: '',
|
|
timestamp: 4,
|
|
toolResults: [{ toolCallId: 'call-new', content: 'file.txt' }],
|
|
},
|
|
]);
|
|
|
|
const result = serializeSessionsForStorage([session], 150 * 1024);
|
|
|
|
assert.ok(result.json.length <= 150 * 1024);
|
|
assert.deepEqual(result.sessions[0].messages.map(message => message.id), [
|
|
'assistant-old',
|
|
'tool-old',
|
|
'assistant-new',
|
|
'tool-new',
|
|
]);
|
|
assert.equal(
|
|
result.sessions[0].messages[0].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
result.sessions[0].messages[2].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
ciphertext,
|
|
);
|
|
});
|
|
|
|
test('serializeSessionsForStorage drops compacted ciphertext before protecting the newest chat', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
|
|
const compactedMessages = Array.from({ length: 17 }, (_, index) => ({
|
|
id: `compacted-${index}`,
|
|
role: 'assistant' as const,
|
|
content: `compacted turn ${index}`,
|
|
timestamp: index,
|
|
providerContinuation: {
|
|
reasoningParts: [{
|
|
text: '',
|
|
providerOptions: {
|
|
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
|
|
},
|
|
}],
|
|
},
|
|
}));
|
|
const newest = {
|
|
...makeSession('newest', 2, [
|
|
...compactedMessages,
|
|
{
|
|
id: 'recent',
|
|
role: 'assistant',
|
|
content: 'recent turn',
|
|
timestamp: 18,
|
|
providerContinuation: {
|
|
reasoningParts: [{
|
|
text: '',
|
|
providerOptions: {
|
|
openai: { itemId: 'rs_recent', reasoningEncryptedContent: ciphertext },
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
]),
|
|
contextCompaction: {
|
|
summary: 'The first 17 turns were summarized.',
|
|
compactedMessageCount: 17,
|
|
},
|
|
};
|
|
const olderVisible = makeSession('older', 1, [{
|
|
id: 'older-visible',
|
|
role: 'user',
|
|
content: 'x'.repeat(600 * 1024),
|
|
timestamp: 1,
|
|
}]);
|
|
|
|
const result = serializeSessionsForStorage([olderVisible, newest]);
|
|
|
|
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'older']);
|
|
assert.ok(result.json.length <= 2 * 1024 * 1024);
|
|
const persistedNewest = result.sessions[0];
|
|
for (const message of persistedNewest.messages.slice(0, 17)) {
|
|
assert.equal(
|
|
message.providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
undefined,
|
|
);
|
|
}
|
|
assert.equal(
|
|
persistedNewest.messages[17].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
ciphertext,
|
|
);
|
|
});
|
|
|
|
test('serializeSessionsForStorage shifts the compaction boundary when trimming old messages', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const ciphertext = 'enc'.repeat(100);
|
|
const messages = Array.from({ length: 250 }, (_, index) => ({
|
|
id: `message-${index}`,
|
|
role: 'assistant' as const,
|
|
content: `turn ${index}`,
|
|
timestamp: index,
|
|
providerContinuation: {
|
|
reasoningParts: [{
|
|
text: '',
|
|
providerOptions: {
|
|
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
|
|
},
|
|
}],
|
|
},
|
|
}));
|
|
const session = {
|
|
...makeSession('trimmed', 1, messages),
|
|
contextCompaction: {
|
|
summary: 'The first 100 messages were summarized.',
|
|
compactedMessageCount: 100,
|
|
},
|
|
};
|
|
|
|
const result = serializeSessionsForStorage([session]);
|
|
const persisted = result.sessions[0];
|
|
|
|
assert.equal(persisted.messages.length, 200);
|
|
assert.equal(persisted.messages[0].id, 'message-50');
|
|
assert.equal(persisted.contextCompaction?.compactedMessageCount, 50);
|
|
assert.equal(
|
|
persisted.messages[49].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
persisted.messages[50].providerContinuation
|
|
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
|
ciphertext,
|
|
);
|
|
});
|
|
|
|
test('serializeSessionsForStorage does not trim between a tool call and its result', async () => {
|
|
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const messages = Array.from({ length: 250 }, (_, index) => ({
|
|
id: `message-${index}`,
|
|
role: 'user' as const,
|
|
content: `turn ${index}`,
|
|
timestamp: index,
|
|
}));
|
|
messages[49] = {
|
|
id: 'assistant-call',
|
|
role: 'assistant',
|
|
content: 'Running it.',
|
|
timestamp: 49,
|
|
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'pwd' } }],
|
|
} as never;
|
|
messages[50] = {
|
|
id: 'tool-result',
|
|
role: 'tool',
|
|
content: '',
|
|
timestamp: 50,
|
|
toolResults: [{ toolCallId: 'call-1', content: '/tmp' }],
|
|
} as never;
|
|
const session = {
|
|
...makeSession('tool-boundary', 1, messages),
|
|
contextCompaction: {
|
|
summary: 'The first 10 messages were summarized.',
|
|
compactedMessageCount: 10,
|
|
},
|
|
};
|
|
|
|
const result = serializeSessionsForStorage([session]);
|
|
const persisted = result.sessions[0];
|
|
|
|
assert.equal(persisted.messages.length, 201);
|
|
assert.equal(persisted.messages[0].id, 'assistant-call');
|
|
assert.equal(persisted.messages[1].id, 'tool-result');
|
|
assert.equal(persisted.contextCompaction?.compactedMessageCount, 0);
|
|
});
|
|
|
|
test('writeSessionsForStorage retries below nominal budgets after a quota failure', async () => {
|
|
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const writes: string[] = [];
|
|
let stored: string | undefined;
|
|
const previousLocalStorage = globalThis.localStorage;
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: {
|
|
getItem: () => null,
|
|
setItem: (_key: string, value: string) => {
|
|
writes.push(value);
|
|
if (value.length > 350 * 1024) {
|
|
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
|
}
|
|
stored = value;
|
|
},
|
|
removeItem: () => {},
|
|
},
|
|
});
|
|
try {
|
|
// The retry loop must attempt progressively smaller payloads (here the
|
|
// ciphertext stripping at the tighter budget) before reporting failure.
|
|
const huge = 'x'.repeat(260 * 1024);
|
|
const ciphertext = 'gAAAA'.repeat(30 * 1024); // ~150 KB
|
|
const sessions = [makeSession('s', 1, [{
|
|
id: 'm',
|
|
role: 'user' as const,
|
|
content: huge,
|
|
timestamp: 0,
|
|
providerContinuation: {
|
|
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
|
},
|
|
}])];
|
|
assert.equal(writeSessionsForStorage(sessions), true);
|
|
assert.equal(writes.length, 2);
|
|
assert.equal(stored, writes[1]);
|
|
assert.ok(writes[writes.length - 1].length < writes[0].length);
|
|
} finally {
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: previousLocalStorage,
|
|
});
|
|
}
|
|
});
|
|
|
|
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
|
|
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const writes: string[] = [];
|
|
let stored: string | undefined;
|
|
const previousLocalStorage = globalThis.localStorage;
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: {
|
|
getItem: () => null,
|
|
setItem: (_key: string, value: string) => {
|
|
writes.push(value);
|
|
if (value.length > 300 * 1024) {
|
|
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
|
}
|
|
stored = value;
|
|
},
|
|
removeItem: () => {},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
|
`session-${index}`,
|
|
10 - index,
|
|
[{
|
|
id: `message-${index}`,
|
|
role: 'user' as const,
|
|
content: 'x'.repeat(40 * 1024),
|
|
timestamp: index,
|
|
}],
|
|
));
|
|
|
|
assert.equal(writeSessionsForStorage(sessions), true);
|
|
assert.ok(writes[0].length > 300 * 1024);
|
|
assert.ok(stored);
|
|
assert.ok(stored.length <= 300 * 1024);
|
|
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
|
|
} finally {
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: previousLocalStorage,
|
|
});
|
|
}
|
|
});
|
|
|
|
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
|
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
|
const writes: string[] = [];
|
|
let stored: string | undefined;
|
|
const previousLocalStorage = globalThis.localStorage;
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: {
|
|
getItem: () => null,
|
|
setItem: (_key: string, value: string) => {
|
|
writes.push(value);
|
|
if (value.length > 50 * 1024) {
|
|
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
|
}
|
|
stored = value;
|
|
},
|
|
removeItem: () => {},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
|
`session-${index}`,
|
|
10 - index,
|
|
[{
|
|
id: `message-${index}`,
|
|
role: 'user' as const,
|
|
content: 'x'.repeat(40 * 1024),
|
|
timestamp: index,
|
|
}],
|
|
));
|
|
|
|
assert.equal(writeSessionsForStorage(sessions), true);
|
|
assert.equal(writes.length, 6);
|
|
assert.ok(stored);
|
|
const persisted = JSON.parse(stored) as AISession[];
|
|
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
|
|
assert.ok(stored.length <= 50 * 1024);
|
|
} finally {
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: previousLocalStorage,
|
|
});
|
|
}
|
|
});
|