fix: harden long conversation continuity
Memind CI / Test, build, and release guards (push) Successful in 1m28s

This commit is contained in:
john
2026-07-27 21:39:34 +08:00
parent 5ae166fc44
commit 48c61a3279
16 changed files with 1635 additions and 94 deletions
+234 -4
View File
@@ -1184,6 +1184,129 @@ test('agent run replaces poisoned Goose session and retries with visible context
assert.ok(pool.events.some((event) => event.eventType === 'poisoned_session_replaced'));
});
test('agent run compacts oversized Goose context before continuing the same logical conversation', async () => {
const pool = createFakePool();
const submitted = [];
const compacted = [];
const priorConversation = [
{
role: 'user',
content: [{ type: 'text', text: '继续修改 public/durable-survey.html' }],
},
{
role: 'assistant',
content: [{ type: 'text', text: 'DURABILITY-26 已完成' }],
},
];
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async compactSessionConversationForUser(userId, sessionId, options) {
compacted.push({ userId, sessionId, options });
return {
compacted: true,
conversation: priorConversation,
messageCount: 903,
charCount: 520_000,
};
},
async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) {
submitted.push({ userId, sessionId, requestId, userMessage });
return { ok: true, finishEvent: { type: 'Finish' } };
},
},
sessionCompactMessageCount: 180,
sessionCompactCharCount: 120_000,
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
sessionId: 'session-durable',
requestId: 'req-durable-27',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '继续修改第 27 轮' }],
metadata: {
memindRun: { sessionMessageCount: 229 },
},
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.equal(compacted.length, 1);
assert.equal(compacted[0].sessionId, 'session-durable');
assert.equal(submitted.length, 1);
assert.equal(submitted[0].sessionId, 'session-durable');
assert.match(submitted[0].userMessage.content[0].text, /会话恢复上下文/);
assert.match(submitted[0].userMessage.content[0].text, /DURABILITY-26 已完成/);
assert.ok(pool.events.some((event) => event.eventType === 'session_context_compacted'));
});
test('agent run transparently rotates oversized context when Goose rejects in-place compaction', async () => {
const pool = createFakePool();
const submitted = [];
const saved = [];
const priorConversation = [
{
role: 'user',
content: [{ type: 'text', text: '继续修改耐久问卷' }],
},
{
role: 'assistant',
content: [{ type: 'text', text: 'DURABILITY-26 已完成' }],
},
];
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async compactSessionConversationForUser() {
const error = new Error('Goose does not support conversation update');
error.code = 'SESSION_CONTEXT_FRESH_SESSION_REQUIRED';
error.conversation = priorConversation;
error.messageCount = 903;
error.charCount = 520_000;
throw error;
},
async startSessionForUser() {
return { id: 'session-durable-rotated' };
},
async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) {
submitted.push({ userId, sessionId, requestId, userMessage });
return { ok: true, finishEvent: { type: 'Finish' } };
},
},
conversationMemoryService: {
async saveConversationMessages(sessionId, userId, messages) {
saved.push({ sessionId, userId, messages });
return messages;
},
},
sessionCompactMessageCount: 180,
sessionCompactCharCount: 120_000,
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
sessionId: 'session-durable-old',
requestId: 'req-durable-rotate',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '继续修改第 27 轮' }],
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.equal(submitted.length, 1);
assert.equal(submitted[0].sessionId, 'session-durable-rotated');
assert.match(submitted[0].userMessage.content[0].text, /会话恢复上下文/);
assert.equal(pool.runs.get(run.id).agent_session_id, 'session-durable-rotated');
assert.equal(saved.length, 1);
assert.equal(saved[0].sessionId, 'session-durable-rotated');
assert.ok(pool.events.some((event) => event.eventType === 'session_context_rotated'));
});
test('agent run replaces reasoning-poisoned Goose session and retries with visible context', async () => {
const pool = createFakePool();
const submitted = [];
@@ -1353,12 +1476,12 @@ test('Page Data run fails closed when Finish arrives without a generated page',
async startSessionForUser() {
return { id: 'session-page-data-missing' };
},
async submitSessionReplyAndAwaitFinishForUser() {
async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) {
if (userMessage?.metadata?.memindRun?.pageDataSuggestionRepair) {
repairSubmits.push({ userId, sessionId, requestId, userMessage });
}
return { ok: true, finishEvent: { type: 'Finish' } };
},
async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
repairSubmits.push({ userId, sessionId, requestId, userMessage });
},
},
syncUserPagesOnSuccess: async () => ({ pageDataBind: { errors: [] } }),
retryDelaysMs: [],
@@ -1386,6 +1509,55 @@ test('Page Data run fails closed when Finish arrives without a generated page',
(event) => event.runId === run.id
&& event.eventType === 'page_data_suggestion_repair_triggered',
));
assert.ok(pool.events.some(
(event) => event.runId === run.id
&& event.eventType === 'page_data_suggestion_repair_completed',
));
});
test('Page Data mutation run cannot reuse a historical page when Finish proves no tool activity', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-page-data-no-tools' };
},
async submitSessionReplyAndAwaitFinishForUser() {
return {
ok: true,
finishEvent: { type: 'Finish' },
toolEvidence: {
calls: [],
successfulCalls: [],
generateImage: { called: false, succeeded: false },
},
};
},
},
syncUserPagesOnSuccess: async () => ({
pageDataBind: { errors: [] },
pageDataRelativePaths: ['public/existing-survey.html'],
}),
retryDelaysMs: [],
enablePageDataSuggestionRepair: false,
});
const run = await gateway.createRun('user-1', {
requestId: 'req-page-data-no-tools',
userMessage: {
role: 'user',
content: [{
type: 'text',
text: '请继续修改 public/existing-survey.html 的 Page Data 调查问卷并保存提交记录',
}],
},
});
await waitFor(() => ['succeeded', 'failed'].includes(pool.runs.get(run.id)?.status));
assert.equal(pool.runs.get(run.id).status, 'failed');
assert.match(pool.runs.get(run.id).error_message, /未执行页面修改工具/);
});
test('Page Data routed status follow-up does not require a new page deliverable', async () => {
@@ -3209,6 +3381,13 @@ test('stale running recovery succeeds when workspace pages exist after sync', as
started_at: startedAt,
updated_at: startedAt,
});
pool.events.push({
id: 'finish-stale-deliverable',
runId: run.id,
eventType: 'session_finished',
dataJson: JSON.stringify({ sessionId: 'session-stale-deliverable' }),
createdAt: startedAt + 2000,
});
const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false });
@@ -3222,6 +3401,57 @@ test('stale running recovery succeeds when workspace pages exist after sync', as
);
});
test('stale running recovery cannot reuse a historical page without session Finish', async () => {
const startedAt = Date.now() - 5000;
const pool = createFakePool({
workspaceDeliverables: {
'user-1': [{
page_id: 'page-historical',
title: '历史问卷',
publication_id: 'pub-historical',
publication_status: 'online',
public_url: 'http://127.0.0.1:5173/u/john/pages/page-historical',
updated_at: startedAt + 1000,
}],
},
});
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
runTimeoutMs: 1000,
syncUserPagesOnSuccess: async () => {},
});
const run = await gateway.createRun('user-1', {
requestId: 'req-stale-historical-page',
sessionId: 'session-stale-historical-page',
userMessage: { role: 'user', content: [] },
});
Object.assign(pool.runs.get(run.id), {
status: 'running',
attempts: 1,
agent_session_id: 'session-stale-historical-page',
started_at: startedAt,
updated_at: startedAt,
});
const result = await gateway.recoverStaleRunningRuns({
staleMs: 1000,
dryRun: false,
});
assert.equal(result.recovered, 1);
assert.equal(pool.runs.get(run.id).status, 'failed');
assert.equal(
pool.events.some(
(event) => event.runId === run.id && event.eventType === 'run_recovered_from_deliverables',
),
false,
);
});
test('queue status reports running heartbeat age and missing heartbeat count', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({