Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions services/cloud-agent-next/wrapper/src/restore-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ function writeMockKilo(binDir: string, exitCode: number): void {
fs.writeFileSync(kiloPath, script, { mode: 0o755 });
}

/** Captures the snapshot path passed to `kilo import` by copying it before exit. */
function writeCapturingMockKilo(binDir: string, capturePath: string): void {
const script = `#!/bin/sh
if [ "$1" = "import" ] && [ -n "$2" ]; then
cp "$2" "${capturePath}"
fi
exit 0
`;
const kiloPath = path.join(binDir, 'kilo');
fs.writeFileSync(kiloPath, script, { mode: 0o755 });
}

function writeSlowMockKilo(binDir: string): void {
const script = '#!/bin/sh\nsleep 1\nexit 0\n';
const kiloPath = path.join(binDir, 'kilo');
Expand Down Expand Up @@ -428,6 +440,60 @@ describe('restoreSession', () => {

// ---- Happy paths ----

it('strips transient lifecycle parts from the snapshot before kilo import', async () => {
const capturePath = path.join(tmpDir, 'import-input.json');
writeCapturingMockKilo(binDir, capturePath);

const snapshot = JSON.stringify({
info: snapshotInfo(),
messages: [
{
info: { role: 'assistant', id: 'msg_1' },
parts: [
{
type: 'text',
text: 'Initializing snapshot…',
metadata: { 'kilocode.lifecycle': 'transient' },
},
{
type: 'text',
text: 'Real assistant reply',
},
{
type: 'text',
text: 'Keep durable metadata',
metadata: { 'kilocode.lifecycle': 'durable' },
},
],
},
{
info: { role: 'user', id: 'msg_2' },
parts: [{ type: 'text', text: 'hello' }],
},
],
});
mockFetchOk(snapshot);

const result = await restoreSession(SESSION_ID, workspace);

expect(result.ok).toBe(true);
expect(fs.existsSync(capturePath)).toBe(true);

const imported = JSON.parse(fs.readFileSync(capturePath, 'utf-8')) as {
messages: Array<{ parts: Array<{ text?: string; metadata?: Record<string, string> }> }>;
};
expect(imported.messages[0]?.parts).toEqual([
{ type: 'text', text: 'Real assistant reply' },
{
type: 'text',
text: 'Keep durable metadata',
metadata: { 'kilocode.lifecycle': 'durable' },
},
]);
expect(imported.messages[1]?.parts).toEqual([{ type: 'text', text: 'hello' }]);
expect(JSON.stringify(imported)).not.toContain('"kilocode.lifecycle":"transient"');
});

it('downloads snapshot, imports, and applies diffs', async () => {
const snapshot = makeSnapshot([
{ file: 'src/index.ts', after: "console.log('hello');", status: 'modified' },
Expand Down
44 changes: 39 additions & 5 deletions services/cloud-agent-next/wrapper/src/restore-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export type RestoreSessionOptions = {
const KILO_IMPORT_TIMEOUT_MS = 120_000;
const JQ_SANITIZE_TOKEN_COUNTS_FILTER =
'walk(if type == "object" and ((.tokens? | type) == "object") then .tokens |= walk(if type == "number" and . < 0 then 0 else . end) else . end)';
// Drop leftover CLI UI progress parts (metadata.kilocode.lifecycle == "transient").
// These leak into durable session history when snapshot progress cleanup fails; on
// restore, toModelMessages copies part.metadata into providerOptions and AI SDK
// rejects string values under providerOptions.kilocode.lifecycle.
const JQ_SANITIZE_TRANSIENT_PARTS_FILTER =
'if (.messages? | type) == "array" then .messages |= map(if type == "object" and (.parts? | type) == "array" then .parts |= map(select((type != "object") or ((.metadata["kilocode.lifecycle"]? // null) != "transient"))) else . end) else . end';
Comment thread
eshurakov marked this conversation as resolved.
Outdated

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -378,14 +384,16 @@ function tokenSanitizationTempPath(snapshotPath: string): string {
);
}

async function sanitizeSnapshotTokenCountsWithJq(
async function sanitizeSnapshotWithJq(
snapshotPath: string,
filter: string,
logLabel: string,
signal?: AbortSignal
): Promise<boolean> {
const tempPath = tokenSanitizationTempPath(snapshotPath);
try {
signal?.throwIfAborted();
const proc = Bun.spawn(['jq', '-c', JQ_SANITIZE_TOKEN_COUNTS_FILTER, snapshotPath], {
const proc = Bun.spawn(['jq', '-c', filter, snapshotPath], {
stdout: 'pipe',
stderr: 'ignore',
signal,
Expand All @@ -395,14 +403,14 @@ async function sanitizeSnapshotTokenCountsWithJq(
await writeOutput;
signal?.throwIfAborted();
if (exitCode !== 0) {
log(`snapshot_token_sanitization_jq_unavailable exitCode=${exitCode}`);
log(`snapshot_${logLabel}_jq_unavailable exitCode=${exitCode}`);
return false;
}
fs.renameSync(tempPath, snapshotPath);
return true;
} catch {
signal?.throwIfAborted();
log('snapshot_token_sanitization_jq_unavailable');
log(`snapshot_${logLabel}_jq_unavailable`);
return false;
} finally {
fs.rmSync(tempPath, { force: true });
Expand All @@ -413,13 +421,38 @@ async function sanitizeSnapshotTokenCounts(
snapshotPath: string,
signal?: AbortSignal
): Promise<void> {
if (await sanitizeSnapshotTokenCountsWithJq(snapshotPath, signal)) {
if (
await sanitizeSnapshotWithJq(
snapshotPath,
JQ_SANITIZE_TOKEN_COUNTS_FILTER,
'token_sanitization',
signal
)
) {
log('snapshot token counts sanitized');
return;
}
log('snapshot token count sanitization skipped');
}

async function sanitizeSnapshotTransientParts(
snapshotPath: string,
signal?: AbortSignal
): Promise<void> {
if (
await sanitizeSnapshotWithJq(
snapshotPath,
JQ_SANITIZE_TRANSIENT_PARTS_FILTER,
'transient_parts_sanitization',
signal
)
) {
log('snapshot transient parts sanitized');
return;
}
log('snapshot transient parts sanitization skipped');
}

// jq filter that extracts diffs from the snapshot JSON using last-write-wins
// deduplication by file path. Runs as a subprocess so the full parsed snapshot
// is never loaded into the main process's heap — jq's C-native parser uses
Expand Down Expand Up @@ -638,6 +671,7 @@ export async function restoreSession(

try {
await sanitizeSnapshotTokenCounts(tmpPath, options.signal);
await sanitizeSnapshotTransientParts(tmpPath, options.signal);
Comment thread
eshurakov marked this conversation as resolved.
Outdated

// ---- Step 2: Run kilo import ----
const importStartedAt = Date.now();
Expand Down
Loading