fix(goose): tighten memory drift checks and add resume smoke gate

Compare memory userId and candidate deltas during manifest drift checks,
add unit tests for false-positive cases, and verify /agent/resume plus
session history loading against local v1.49.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-08 21:27:14 +08:00
parent 716ef407fe
commit fe8a5e74d1
5 changed files with 269 additions and 6 deletions
+1
View File
@@ -33,6 +33,7 @@ const checks = [
required: true,
},
{ name: 'reply-smoke', script: 'check-goosed-v149-reply-smoke.mjs', required: true },
{ name: 'resume', script: 'check-goosed-v149-resume.mjs', required: true },
{ name: 'sandbox-fs', script: 'check-goosed-v149-sandbox-fs.mjs', required: true },
{ name: 'executors', script: 'check-goosed-v149-executors.mjs', required: true },
{ name: 'memory-loop', script: 'check-goosed-v149-memory-loop.mjs', required: true },
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Verify v1.49 session history + /agent/resume parity with goose-server 1.41.
*/
import { createV149Client } from './goose-v149-sse.mjs';
const client = createV149Client();
function messageCount(session) {
const conversation = session?.conversation;
if (!conversation) return 0;
if (Array.isArray(conversation.messages)) return conversation.messages.length;
if (Array.isArray(conversation)) return conversation.length;
return Number(session?.message_count ?? session?.messageCount ?? 0);
}
async function main() {
const list = await client.apiJson('/sessions', undefined, { method: 'GET' });
const sessions = list?.sessions ?? [];
const rich = sessions
.slice()
.sort((a, b) => Number(b.message_count ?? b.messageCount ?? 0) - Number(a.message_count ?? a.messageCount ?? 0))
.find((item) => Number(item.message_count ?? item.messageCount ?? 0) > 0);
if (!rich?.id) {
throw new Error('no session with message_count > 0; run check-goosed-v149-provider.mjs first');
}
const sessionId = rich.id;
const detailRes = await client.apiFetch(`/sessions/${sessionId}`, { method: 'GET' });
const detailText = await detailRes.text();
if (!detailRes.ok) {
throw new Error(`GET /sessions/{id} ${detailRes.status}: ${detailText.slice(0, 400)}`);
}
const detail = JSON.parse(detailText);
const beforeCount = messageCount(detail);
if (beforeCount <= 0) {
throw new Error(
`GET /sessions/{id} returned empty conversation for message_count=${rich.message_count ?? rich.messageCount}`,
);
}
const resume = await client.apiJson('/agent/resume', {
session_id: sessionId,
load_model_and_extensions: true,
});
if (!resume?.session?.id) {
throw new Error('POST /agent/resume missing session');
}
const afterCount = messageCount(resume.session);
if (afterCount <= 0) {
throw new Error('POST /agent/resume returned session without conversation history');
}
const restart = await client.apiJson('/agent/restart', { session_id: sessionId });
if (!Array.isArray(restart?.extension_results)) {
throw new Error('POST /agent/restart missing extension_results array');
}
console.log(
`GOOSE_V149_RESUME_OK: session=${sessionId} messages=${afterCount} `
+ `restartExtensions=${restart.extension_results.length}`,
);
}
main().catch((error) => {
console.error(`GOOSE_V149_RESUME_FAIL: ${error.message}`);
process.exit(1);
});
+57 -6
View File
@@ -57,6 +57,14 @@ function indexMemories(manifest) {
return byId;
}
function indexCandidates(manifest) {
const byId = new Map();
for (const item of manifest.candidates ?? []) {
byId.set(item.id, item);
}
return byId;
}
function compareManifests(baseline, current) {
const drift = {
baselineDigest: baseline.summary?.digest ?? null,
@@ -69,21 +77,30 @@ function compareManifests(baseline, current) {
addedMemoryIds: [],
removedMemoryIds: [],
changedMemories: [],
addedCandidateIds: [],
removedCandidateIds: [],
changedCandidates: [],
};
const base = indexMemories(baseline);
const cur = indexMemories(current);
for (const [id, item] of cur) {
for (const [id] of cur) {
if (!base.has(id)) drift.addedMemoryIds.push(id);
}
for (const [id, item] of base) {
for (const [id] of base) {
if (!cur.has(id)) drift.removedMemoryIds.push(id);
}
for (const [id, baseItem] of base) {
const curItem = cur.get(id);
if (!curItem) continue;
const fields = ['memoryHash', 'status', 'sourceSessionId', 'evidenceMessageId'];
const fields = [
'userId',
'memoryHash',
'status',
'sourceSessionId',
'evidenceMessageId',
];
const changes = {};
for (const field of fields) {
if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) {
@@ -95,10 +112,37 @@ function compareManifests(baseline, current) {
}
}
const baseCandidates = indexCandidates(baseline);
const curCandidates = indexCandidates(current);
for (const [id] of curCandidates) {
if (!baseCandidates.has(id)) drift.addedCandidateIds.push(id);
}
for (const [id] of baseCandidates) {
if (!curCandidates.has(id)) drift.removedCandidateIds.push(id);
}
for (const [id, baseItem] of baseCandidates) {
const curItem = curCandidates.get(id);
if (!curItem) continue;
const fields = ['userId', 'status', 'sourceSessionId'];
const changes = {};
for (const field of fields) {
if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) {
changes[field] = { baseline: baseItem[field] ?? null, current: curItem[field] ?? null };
}
}
if (Object.keys(changes).length) {
drift.changedCandidates.push({ id, userId: baseItem.userId, changes });
}
}
drift.ok =
drift.addedMemoryIds.length === 0
&& drift.removedMemoryIds.length === 0
&& drift.changedMemories.length === 0;
&& drift.changedMemories.length === 0
&& drift.addedCandidateIds.length === 0
&& drift.removedCandidateIds.length === 0
&& drift.changedCandidates.length === 0
&& drift.summary.candidateCountDelta === 0;
return drift;
}
@@ -125,11 +169,18 @@ async function main() {
if (args.json) {
console.log(JSON.stringify(report, null, 2));
} else if (report.ok) {
console.log('GOOSE_V149_MEMORY_DRIFT_OK: no ID/hash/status/session/evidence drift');
console.log('GOOSE_V149_MEMORY_DRIFT_OK: no user/memory/candidate drift');
console.log(` baselineDigest=${report.baselineDigest}`);
} else {
console.error('GOOSE_V149_MEMORY_DRIFT_FAIL:');
console.error(` added=${report.addedMemoryIds.length} removed=${report.removedMemoryIds.length} changed=${report.changedMemories.length}`);
console.error(
` memory added=${report.addedMemoryIds.length} removed=${report.removedMemoryIds.length} `
+ `changed=${report.changedMemories.length}`,
);
console.error(
` candidate added=${report.addedCandidateIds.length} removed=${report.removedCandidateIds.length} `
+ `changed=${report.changedCandidates.length} delta=${report.summary.candidateCountDelta}`,
);
if (report.addedMemoryIds.length) {
console.error(` added sample: ${report.addedMemoryIds.slice(0, 5).join(', ')}`);
}
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Inline compare logic by importing via dynamic eval of exported function -
// the script only has main(); duplicate core compare here for unit tests.
function indexMemories(manifest) {
const byId = new Map();
for (const item of manifest.memories ?? []) {
byId.set(item.id, item);
}
return byId;
}
function indexCandidates(manifest) {
const byId = new Map();
for (const item of manifest.candidates ?? []) {
byId.set(item.id, item);
}
return byId;
}
function compareManifests(baseline, current) {
const drift = {
summary: {
candidateCountDelta:
(current.summary?.candidateCount ?? 0) - (baseline.summary?.candidateCount ?? 0),
},
addedMemoryIds: [],
removedMemoryIds: [],
changedMemories: [],
addedCandidateIds: [],
removedCandidateIds: [],
changedCandidates: [],
};
const base = indexMemories(baseline);
const cur = indexMemories(current);
for (const [id] of cur) {
if (!base.has(id)) drift.addedMemoryIds.push(id);
}
for (const [id] of base) {
if (!cur.has(id)) drift.removedMemoryIds.push(id);
}
for (const [id, baseItem] of base) {
const curItem = cur.get(id);
if (!curItem) continue;
const fields = [
'userId',
'memoryHash',
'status',
'sourceSessionId',
'evidenceMessageId',
];
const changes = {};
for (const field of fields) {
if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) {
changes[field] = { baseline: baseItem[field] ?? null, current: curItem[field] ?? null };
}
}
if (Object.keys(changes).length) {
drift.changedMemories.push({ id, changes });
}
}
const baseCandidates = indexCandidates(baseline);
const curCandidates = indexCandidates(current);
for (const [id] of curCandidates) {
if (!baseCandidates.has(id)) drift.addedCandidateIds.push(id);
}
for (const [id] of baseCandidates) {
if (!curCandidates.has(id)) drift.removedCandidateIds.push(id);
}
for (const [id, baseItem] of baseCandidates) {
const curItem = curCandidates.get(id);
if (!curItem) continue;
const fields = ['userId', 'status', 'sourceSessionId'];
const changes = {};
for (const field of fields) {
if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) {
changes[field] = { baseline: baseItem[field] ?? null, current: curItem[field] ?? null };
}
}
if (Object.keys(changes).length) {
drift.changedCandidates.push({ id, changes });
}
}
drift.ok =
drift.addedMemoryIds.length === 0
&& drift.removedMemoryIds.length === 0
&& drift.changedMemories.length === 0
&& drift.addedCandidateIds.length === 0
&& drift.removedCandidateIds.length === 0
&& drift.changedCandidates.length === 0
&& drift.summary.candidateCountDelta === 0;
return drift;
}
test('memory drift fails when userId changes for the same memory id', () => {
const baseline = {
summary: { candidateCount: 1 },
memories: [{ id: 'm1', userId: 'u1', memoryHash: 'h1', status: 'active' }],
candidates: [{ id: 'c1', userId: 'u1', status: 'pending' }],
};
const current = {
summary: { candidateCount: 6 },
memories: [{ id: 'm1', userId: 'u2', memoryHash: 'h1', status: 'active' }],
candidates: [
{ id: 'c1', userId: 'u1', status: 'pending' },
{ id: 'c2', userId: 'u1', status: 'pending' },
{ id: 'c3', userId: 'u1', status: 'pending' },
{ id: 'c4', userId: 'u1', status: 'pending' },
{ id: 'c5', userId: 'u1', status: 'pending' },
{ id: 'c6', userId: 'u1', status: 'pending' },
],
};
const report = compareManifests(baseline, current);
assert.equal(report.ok, false);
assert.equal(report.changedMemories.length, 1);
assert.equal(report.changedMemories[0].changes.userId.baseline, 'u1');
assert.equal(report.summary.candidateCountDelta, 5);
assert.ok(report.addedCandidateIds.length >= 5);
});
test('memory drift passes for identical manifests', () => {
const manifest = {
summary: { candidateCount: 0 },
memories: [{ id: 'm1', userId: 'u1', memoryHash: 'h1', status: 'active' }],
candidates: [],
};
const report = compareManifests(manifest, structuredClone(manifest));
assert.equal(report.ok, true);
});
+5
View File
@@ -21,6 +21,11 @@ const checks = [
command: [process.execPath, '--test', 'scripts/goose-v149-canary.test.mjs'],
required: true,
},
{
name: 'memory-manifest-unit',
command: [process.execPath, '--test', 'scripts/compare-goose-v149-memory-manifest.test.mjs'],
required: true,
},
{
name: 'memory-drift-again',
script: 'compare-goose-v149-memory-manifest.mjs',