fix: harden long conversation continuity
Memind CI / Test, build, and release guards (push) Successful in 1m28s
Memind CI / Test, build, and release guards (push) Successful in 1m28s
This commit is contained in:
+234
-4
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user