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:
@@ -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 },
|
||||
|
||||
@@ -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 +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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user