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:
+150
-18
@@ -154,6 +154,14 @@ function pageDataFailureCheckId(code) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasPageMutationToolEvidence(toolEvidence) {
|
||||
if (!toolEvidence || !Array.isArray(toolEvidence.calls)) return null;
|
||||
return toolEvidence.calls.some((name) =>
|
||||
/(?:^|[-_:])(edit|write|publish|workspace|page[-_]?data)(?:[-_:]|$)/i.test(
|
||||
String(name ?? ''),
|
||||
));
|
||||
}
|
||||
|
||||
function buildFreshSessionContext(conversation, { maxMessages = 16, maxChars = 12_000 } = {}) {
|
||||
const visible = [];
|
||||
for (const message of Array.isArray(conversation) ? conversation : []) {
|
||||
@@ -219,6 +227,7 @@ const FRESH_SESSION_RECOVERY_CODES = new Set([
|
||||
'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED',
|
||||
'SESSION_REASONING_CONTENT_POISONED',
|
||||
'SESSION_VISUAL_CONTEXT_UNSUPPORTED',
|
||||
'SESSION_EMPTY_FINISH',
|
||||
]);
|
||||
|
||||
async function resolveConversationForFreshSessionRecovery({
|
||||
@@ -231,14 +240,20 @@ async function resolveConversationForFreshSessionRecovery({
|
||||
if (err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED') {
|
||||
return Array.isArray(err?.repairedConversation) ? err.repairedConversation : [];
|
||||
}
|
||||
let gooseConversation = [];
|
||||
if (typeof tkmindProxy?.fetchSessionConversationForUser === 'function') {
|
||||
const conversation = await tkmindProxy.fetchSessionConversationForUser(
|
||||
gooseConversation = await tkmindProxy.fetchSessionConversationForUser(
|
||||
userId,
|
||||
previousSessionId,
|
||||
).catch(() => []);
|
||||
if (conversation.length > 0) return conversation;
|
||||
}
|
||||
return loadSnapshotMessages(sessionSnapshotService, previousSessionId);
|
||||
const snapshotConversation = await loadSnapshotMessages(
|
||||
sessionSnapshotService,
|
||||
previousSessionId,
|
||||
);
|
||||
return snapshotConversation.length > gooseConversation.length
|
||||
? snapshotConversation
|
||||
: gooseConversation;
|
||||
}
|
||||
|
||||
function resolveRequiredImageGeneration(row, routing) {
|
||||
@@ -597,6 +612,14 @@ export function createAgentRunGateway({
|
||||
process.env.MEMIND_PAGE_DATA_SUGGESTION_REPAIR_ENABLED,
|
||||
true,
|
||||
),
|
||||
sessionCompactMessageCount = positiveInteger(
|
||||
process.env.MEMIND_AGENT_SESSION_COMPACT_MESSAGE_COUNT,
|
||||
180,
|
||||
),
|
||||
sessionCompactCharCount = positiveInteger(
|
||||
process.env.MEMIND_AGENT_SESSION_COMPACT_CHAR_COUNT,
|
||||
120_000,
|
||||
),
|
||||
workerIdentity = null,
|
||||
}) {
|
||||
const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {});
|
||||
@@ -829,7 +852,10 @@ export function createAgentRunGateway({
|
||||
if (!normalizedSessionId || isDirectChatSessionId(normalizedSessionId)) {
|
||||
return { triggered: false, reason: 'no_session' };
|
||||
}
|
||||
if (!tkmindProxy?.submitSessionReplyForUser) {
|
||||
if (
|
||||
!tkmindProxy?.submitSessionReplyForUser
|
||||
&& !tkmindProxy?.submitSessionReplyAndAwaitFinishForUser
|
||||
) {
|
||||
return { triggered: false, reason: 'no_proxy' };
|
||||
}
|
||||
if (suggestionRepairTriggered.has(runId)) {
|
||||
@@ -852,17 +878,32 @@ export function createAgentRunGateway({
|
||||
},
|
||||
};
|
||||
try {
|
||||
await tkmindProxy.submitSessionReplyForUser(
|
||||
userId,
|
||||
normalizedSessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
);
|
||||
await appendEvent(runId, 'page_data_suggestion_repair_triggered', {
|
||||
code: String(code ?? '').slice(0, 128),
|
||||
requestId,
|
||||
sessionId: normalizedSessionId,
|
||||
});
|
||||
if (typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function') {
|
||||
const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
|
||||
userId,
|
||||
normalizedSessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
{ timeoutMs: Math.min(runTimeoutMs, 120_000) },
|
||||
);
|
||||
await appendEvent(runId, 'page_data_suggestion_repair_completed', {
|
||||
requestId,
|
||||
sessionId: normalizedSessionId,
|
||||
toolCalls: finish?.toolEvidence?.calls ?? [],
|
||||
});
|
||||
} else {
|
||||
await tkmindProxy.submitSessionReplyForUser(
|
||||
userId,
|
||||
normalizedSessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
);
|
||||
}
|
||||
return { triggered: true, requestId };
|
||||
} catch (err) {
|
||||
await appendEvent(runId, 'page_data_suggestion_repair_failed', {
|
||||
@@ -1648,6 +1689,81 @@ export function createAgentRunGateway({
|
||||
saved: transcriptPersisted.saved,
|
||||
});
|
||||
}
|
||||
if (typeof tkmindProxy.compactSessionConversationForUser === 'function') {
|
||||
try {
|
||||
const compaction = await tkmindProxy.compactSessionConversationForUser(
|
||||
row.user_id,
|
||||
sessionId,
|
||||
{
|
||||
messageThreshold: sessionCompactMessageCount,
|
||||
charThreshold: sessionCompactCharCount,
|
||||
},
|
||||
);
|
||||
if (compaction?.compacted) {
|
||||
userMessage = appendFreshSessionContext(
|
||||
userMessage,
|
||||
compaction.conversation,
|
||||
);
|
||||
await appendEvent(runId, 'session_context_compacted', {
|
||||
sessionId,
|
||||
messageCount: compaction.messageCount ?? null,
|
||||
charCount: compaction.charCount ?? null,
|
||||
clientMessageCount: runOptions.sessionMessageCount,
|
||||
retainedContextMessages: 16,
|
||||
retainedContextChars: 12_000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
err?.code === 'SESSION_CONTEXT_FRESH_SESSION_REQUIRED'
|
||||
&& typeof tkmindProxy.startSessionForUser === 'function'
|
||||
) {
|
||||
const previousSessionId = sessionId;
|
||||
const conversationForContext = Array.isArray(err?.conversation)
|
||||
? err.conversation
|
||||
: await resolveConversationForFreshSessionRecovery({
|
||||
err,
|
||||
previousSessionId,
|
||||
userId: row.user_id,
|
||||
tkmindProxy,
|
||||
sessionSnapshotService,
|
||||
});
|
||||
const replacement = await tkmindProxy.startSessionForUser(row.user_id);
|
||||
sessionId = replacement.id;
|
||||
await pool.query(
|
||||
`UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
|
||||
[sessionId, nowMs(), runId],
|
||||
);
|
||||
const persisted = await persistSessionTranscriptMessages({
|
||||
conversationMemoryService,
|
||||
sessionId,
|
||||
userId: row.user_id,
|
||||
messages: conversationForContext,
|
||||
});
|
||||
userMessage = appendFreshSessionContext(
|
||||
userMessage,
|
||||
conversationForContext,
|
||||
);
|
||||
await appendEvent(runId, 'session_context_rotated', {
|
||||
previousSessionId,
|
||||
sessionId,
|
||||
reason: err.code,
|
||||
messageCount: err?.messageCount ?? conversationForContext.length,
|
||||
charCount: err?.charCount ?? null,
|
||||
persistedMessageCount: persisted.saved,
|
||||
retainedContextMessages: 16,
|
||||
retainedContextChars: 12_000,
|
||||
});
|
||||
await appendRunSnapshot(runId);
|
||||
} else {
|
||||
await appendEvent(runId, 'session_context_compaction_failed', {
|
||||
sessionId,
|
||||
code: String(err?.code ?? '').slice(0, 128),
|
||||
message: String(err instanceof Error ? err.message : err).slice(0, 500),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await invalidatePortalDirectChatSnapshot(sessionId);
|
||||
let toolEvidence = null;
|
||||
const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true)
|
||||
@@ -1833,6 +1949,18 @@ export function createAgentRunGateway({
|
||||
pageGenerationIntent
|
||||
&& !isGenericPageGenerationRequest(runDisplayText)
|
||||
));
|
||||
if (requiresPageDeliverable && hasPageMutationToolEvidence(toolEvidence) === false) {
|
||||
const error = new Error(
|
||||
pageDataIntent
|
||||
? 'Page Data 任务未执行页面修改工具,不能用历史页面标记成功'
|
||||
: '页面任务未执行写入或发布工具,不能用历史页面标记成功',
|
||||
);
|
||||
error.code = pageDataIntent
|
||||
? 'PAGE_DATA_DELIVERABLE_MISSING'
|
||||
: 'PUBLIC_PAGE_DELIVERABLE_MISSING';
|
||||
error.retryable = false;
|
||||
throw error;
|
||||
}
|
||||
let deliverables = null;
|
||||
if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') {
|
||||
const latest = await getRunById(runId);
|
||||
@@ -2277,14 +2405,18 @@ export function createAgentRunGateway({
|
||||
new Error(`agent run stale after ${ageMs}ms`),
|
||||
{ code: 'AGENT_RUN_STALE_RECOVERY' },
|
||||
);
|
||||
const deliverableRecovered = await recoverRunFromDeliverables({
|
||||
runId: row.id,
|
||||
userId: row.user_id,
|
||||
sessionId: row.agent_session_id ?? null,
|
||||
err: staleError,
|
||||
runStartedAtMs: row.started_at ?? null,
|
||||
requireRecoverableError: false,
|
||||
});
|
||||
// A synchronized historical page can receive fresh database timestamps
|
||||
// without this run having executed any model or file tool. Only recover
|
||||
// a stale run as successful after its own session Finish was observed.
|
||||
const deliverableRecovered = sessionFinishedAt != null
|
||||
&& await recoverRunFromDeliverables({
|
||||
runId: row.id,
|
||||
userId: row.user_id,
|
||||
sessionId: row.agent_session_id ?? null,
|
||||
err: staleError,
|
||||
runStartedAtMs: row.started_at ?? null,
|
||||
requireRecoverableError: false,
|
||||
});
|
||||
if (deliverableRecovered) {
|
||||
const marked = await markRun(row.id, 'succeeded', {
|
||||
agent_session_id: row.agent_session_id ?? null,
|
||||
|
||||
+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({
|
||||
|
||||
@@ -31,6 +31,47 @@ export function shouldPromoteSessionIdToStreaming(chatState) {
|
||||
return chatState !== 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* A newly created Goose session can replay an empty Finish frame before the
|
||||
* current model request produces any activity. While a visible request is
|
||||
* active, that frame must not unlock the composer.
|
||||
*
|
||||
* @param {{ eventType?: string; tokenState?: object | null; hasActiveRequest?: boolean }} input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldIgnoreZeroActivityFinish({
|
||||
eventType,
|
||||
tokenState,
|
||||
hasActiveRequest = false,
|
||||
} = {}) {
|
||||
if (eventType !== 'Finish' || !hasActiveRequest || !tokenState) return false;
|
||||
const values = [
|
||||
tokenState.inputTokens,
|
||||
tokenState.outputTokens,
|
||||
tokenState.totalTokens,
|
||||
tokenState.accumulatedInputTokens,
|
||||
tokenState.accumulatedOutputTokens,
|
||||
tokenState.accumulatedTotalTokens,
|
||||
].filter((value) => value != null);
|
||||
return values.length > 0 && values.every((value) => Number(value) === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session stream can temporarily report no Goose request while the
|
||||
* Portal agent-run is still authoritative and pending. Do not start the
|
||||
* missing-request idle timer until that outer run has reached a terminal
|
||||
* state.
|
||||
*
|
||||
* @param {{ allowMissingGrace?: boolean; agentRunPending?: boolean }} input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldScheduleMissingActiveRequestGrace({
|
||||
allowMissingGrace = false,
|
||||
agentRunPending = false,
|
||||
} = {}) {
|
||||
return allowMissingGrace && !agentRunPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only transport uncertainty may continue a run after submit fails. A
|
||||
* deterministic gateway error already has a terminal outcome and must return
|
||||
|
||||
@@ -3,8 +3,10 @@ import test from 'node:test';
|
||||
import {
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldIgnoreZeroActivityFinish,
|
||||
shouldKeepStreamingAfterRunError,
|
||||
shouldPromoteSessionIdToStreaming,
|
||||
shouldScheduleMissingActiveRequestGrace,
|
||||
} from './chat-agent-run-gate.mjs';
|
||||
|
||||
test('resolvePostAgentRunChatState keeps idle when Finish beat agent-run gate', () => {
|
||||
@@ -85,6 +87,64 @@ test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () =>
|
||||
assert.equal(shouldPromoteSessionIdToStreaming('streaming'), true);
|
||||
});
|
||||
|
||||
test('zero-token Finish cannot unlock a composer with an active request', () => {
|
||||
assert.equal(
|
||||
shouldIgnoreZeroActivityFinish({
|
||||
eventType: 'Finish',
|
||||
hasActiveRequest: true,
|
||||
tokenState: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
accumulatedInputTokens: 0,
|
||||
accumulatedOutputTokens: 0,
|
||||
accumulatedTotalTokens: 0,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldIgnoreZeroActivityFinish({
|
||||
eventType: 'Finish',
|
||||
hasActiveRequest: true,
|
||||
tokenState: { inputTokens: 8, outputTokens: 2, totalTokens: 10 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldIgnoreZeroActivityFinish({
|
||||
eventType: 'Finish',
|
||||
hasActiveRequest: false,
|
||||
tokenState: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing ActiveRequests cannot unlock while the Portal agent-run is pending', () => {
|
||||
assert.equal(
|
||||
shouldScheduleMissingActiveRequestGrace({
|
||||
allowMissingGrace: true,
|
||||
agentRunPending: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldScheduleMissingActiveRequestGrace({
|
||||
allowMissingGrace: true,
|
||||
agentRunPending: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldScheduleMissingActiveRequestGrace({
|
||||
allowMissingGrace: false,
|
||||
agentRunPending: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
|
||||
assert.deepEqual(
|
||||
reconcileSessionEventRequestContext({
|
||||
|
||||
@@ -27,6 +27,27 @@ npm run verify:mindspace-page-sync-guards
|
||||
|
||||
涉及 H5 交付时,还必须验证:未注册 dataset 时不产生可用 Page Data policy,且最终链接交付被拒绝或进入明确 repair 状态。
|
||||
|
||||
## Finish 异步收尾不得留下永久 preparing
|
||||
|
||||
Portal 的 session SSE 在收到 Finish 后会异步执行页面同步、HTML 守卫、Page Data
|
||||
绑定检查和 delivery contract ready 标记。该收尾链路是幂等的;任一瞬时异常不得被
|
||||
静默吞掉并把已完成页面永久留在 `preparing`。
|
||||
|
||||
- `server/portal-session-routes.mjs` 必须对整段 Finish delivery finalization
|
||||
执行有限重试,并记录包含 session 与 attempt 的警告
|
||||
- 已产生本轮 public HTML 时,HTML 或 Page Data 守卫返回非 ready 也必须进入
|
||||
有限重试;不能把 `limit`、`triggered` 等中间状态当成成功收尾
|
||||
- `tkmind-proxy.mjs` 收到 SSE Finish 后必须先独立启动页面收尾,再执行计费;
|
||||
重复计费或计费服务异常不得跳过 delivery finalization
|
||||
- workspace-backed 资产版本使用稳定的 `workspace://` storage key;工作区文件更新
|
||||
必须锁定资产并原位更新当前版本记录,禁止为同一 storage key 并发插入新 version
|
||||
- 每次尝试必须成对调用 `beginSessionPageDelivery` /
|
||||
`endSessionPageDelivery`,失败后不得遗留内存 busy 状态
|
||||
- 只有 HTML 与 Page Data 两道守卫都通过,才允许调用
|
||||
`markPageDeliveryContractReady`
|
||||
- 回归用例:`server/portal-session-routes.test.mjs` 中的 transient
|
||||
post-Finish failure retry 场景
|
||||
|
||||
## PostgreSQL 用户空间角色守卫
|
||||
|
||||
生产用户空间 PostgreSQL(独立于 Goose session PostgreSQL)通过 `SET LOCAL ROLE ms_u_*_agent` 隔离每个用户。必须保留以下约束:
|
||||
|
||||
@@ -302,11 +302,17 @@ export function createWorkspaceAssetSync({
|
||||
if (!space || space.status !== 'active') {
|
||||
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
||||
}
|
||||
const sizeDelta = buffer.length - asNumber(existing.size_bytes);
|
||||
const available =
|
||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
||||
if (sizeDelta > 0 && available < sizeDelta) {
|
||||
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
|
||||
const [assetRows] = await conn.query(
|
||||
`SELECT id, current_version_id, size_bytes, checksum
|
||||
FROM h5_assets
|
||||
WHERE id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE`,
|
||||
[existing.id, userId],
|
||||
);
|
||||
const currentAsset = assetRows[0];
|
||||
if (!currentAsset) {
|
||||
throw Object.assign(new Error('工作区资产不存在'), { code: 'asset_not_found' });
|
||||
}
|
||||
|
||||
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
|
||||
@@ -321,19 +327,36 @@ export function createWorkspaceAssetSync({
|
||||
const assetStatus = assetStatusForScan(scan);
|
||||
const versionScanStatus = versionStatusForScan(scan);
|
||||
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const [versionRows] = await conn.query(
|
||||
`SELECT COALESCE(MAX(version_no), 0) AS max_version
|
||||
FROM h5_asset_versions
|
||||
WHERE asset_id = ?`,
|
||||
[existing.id],
|
||||
);
|
||||
const versionNo = asNumber(versionRows[0]?.max_version) + 1;
|
||||
const versionId = idFactory();
|
||||
const finalStorageKey = buildWorkspaceStorageKey(
|
||||
userId,
|
||||
category.category_code,
|
||||
file.filename,
|
||||
);
|
||||
if (currentAsset.checksum === checksum) {
|
||||
await conn.commit();
|
||||
return {
|
||||
action: 'skipped',
|
||||
assetId: currentAsset.id,
|
||||
filename: file.filename,
|
||||
checksum,
|
||||
mimeType: detectedMimeType,
|
||||
sizeBytes: buffer.length,
|
||||
storageKey: finalStorageKey,
|
||||
categoryCode: category.category_code,
|
||||
};
|
||||
}
|
||||
const sizeDelta = buffer.length - asNumber(currentAsset.size_bytes);
|
||||
const available =
|
||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
||||
if (sizeDelta > 0 && available < sizeDelta) {
|
||||
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
|
||||
}
|
||||
const versionId = currentAsset.current_version_id;
|
||||
if (!versionId) {
|
||||
throw Object.assign(new Error('工作区资产版本不存在'), {
|
||||
code: 'asset_version_missing',
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
|
||||
@@ -342,11 +365,10 @@ export function createWorkspaceAssetSync({
|
||||
});
|
||||
await conn.query(
|
||||
`UPDATE h5_assets
|
||||
SET current_version_id = ?, size_bytes = ?, checksum = ?, mime_type = ?,
|
||||
SET size_bytes = ?, checksum = ?, mime_type = ?,
|
||||
asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
versionId,
|
||||
buffer.length,
|
||||
checksum,
|
||||
detectedMimeType,
|
||||
@@ -360,21 +382,19 @@ export function createWorkspaceAssetSync({
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_asset_versions
|
||||
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
|
||||
created_by, change_note, scan_status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '工作区文件更新', ?, ?)`,
|
||||
`UPDATE h5_asset_versions
|
||||
SET size_bytes = ?, checksum = ?, mime_type = ?, created_by = ?,
|
||||
change_note = '工作区文件更新', scan_status = ?, created_at = ?
|
||||
WHERE id = ? AND asset_id = ?`,
|
||||
[
|
||||
versionId,
|
||||
existing.id,
|
||||
versionNo,
|
||||
finalStorageKey,
|
||||
buffer.length,
|
||||
checksum,
|
||||
detectedMimeType,
|
||||
userId,
|
||||
versionScanStatus,
|
||||
now,
|
||||
versionId,
|
||||
currentAsset.id,
|
||||
],
|
||||
);
|
||||
if (sizeDelta !== 0) {
|
||||
@@ -387,7 +407,7 @@ export function createWorkspaceAssetSync({
|
||||
await conn.commit();
|
||||
return {
|
||||
action: 'updated',
|
||||
assetId: existing.id,
|
||||
assetId: currentAsset.id,
|
||||
filename: file.filename,
|
||||
checksum,
|
||||
mimeType: detectedMimeType,
|
||||
@@ -484,8 +504,15 @@ export function createWorkspaceAssetSync({
|
||||
}
|
||||
if (existing) {
|
||||
const result = await updateWorkspaceFile(userId, category, existing, file, buffer);
|
||||
if (result.action === 'skipped') {
|
||||
existing.checksum = checksum;
|
||||
existing.size_bytes = buffer.length;
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await registerWorkspaceArtifactForConversation(userId, source, result);
|
||||
existing.checksum = checksum;
|
||||
existing.size_bytes = buffer.length;
|
||||
updated += 1;
|
||||
} else {
|
||||
const result = await importWorkspaceFile(userId, category, file, buffer);
|
||||
|
||||
@@ -106,6 +106,12 @@ test('syncUserWorkspace imports new workspace files into asset library', async (
|
||||
);
|
||||
return [space ? [space] : []];
|
||||
}
|
||||
if (sql.includes('FROM h5_assets') && sql.includes('FOR UPDATE')) {
|
||||
const asset = state.assets.find(
|
||||
(item) => item.id === params[0] && item.user_id === params[1],
|
||||
);
|
||||
return [asset ? [asset] : []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO h5_assets')) {
|
||||
state.assets.push({
|
||||
id: params[0],
|
||||
@@ -123,18 +129,53 @@ test('syncUserWorkspace imports new workspace files into asset library', async (
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('INSERT INTO h5_asset_versions')) {
|
||||
const storageKey = sql.includes('VALUES (?, ?, 1')
|
||||
? params[2]
|
||||
: params[3];
|
||||
if (state.versions.some((item) => item.storage_key === storageKey)) {
|
||||
const error = new Error(`Duplicate storage key ${storageKey}`);
|
||||
error.code = 'ER_DUP_ENTRY';
|
||||
throw error;
|
||||
}
|
||||
state.versions.push({
|
||||
id: params[0],
|
||||
asset_id: params[1],
|
||||
storage_key: params[2],
|
||||
storage_key: storageKey,
|
||||
scan_status: params[7],
|
||||
});
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('UPDATE h5_assets') && sql.includes('SET size_bytes =')) {
|
||||
const asset = state.assets.find(
|
||||
(item) => item.id === params[8] && item.user_id === params[9],
|
||||
);
|
||||
asset.size_bytes = params[0];
|
||||
asset.checksum = params[1];
|
||||
asset.mime_type = params[2];
|
||||
asset.asset_type = params[3];
|
||||
asset.risk_level = params[4];
|
||||
asset.status = params[5];
|
||||
asset.workspace_relative_path = params[6];
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('UPDATE h5_asset_versions')) {
|
||||
const version = state.versions.find(
|
||||
(item) => item.id === params[6] && item.asset_id === params[7],
|
||||
);
|
||||
version.size_bytes = params[0];
|
||||
version.checksum = params[1];
|
||||
version.mime_type = params[2];
|
||||
version.scan_status = params[4];
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('used_bytes = used_bytes +')) {
|
||||
state.spaces[0].used_bytes += params[0];
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('used_bytes = GREATEST')) {
|
||||
state.spaces[0].used_bytes += params[0];
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('COALESCE(MAX(version_no)')) {
|
||||
return [[{ max_version: 0 }]];
|
||||
}
|
||||
@@ -203,6 +244,16 @@ test('syncUserWorkspace imports new workspace files into asset library', async (
|
||||
'writeManifestForSession',
|
||||
{ userId: 'user-1', sessionId: 'session-1' },
|
||||
]);
|
||||
|
||||
await fs.writeFile(path.join(workspace, 'oa', 'memo.txt'), 'updated workspace\n');
|
||||
const updated = await sync.syncUserWorkspace('user-1', {
|
||||
categoryCode: 'oa',
|
||||
sourceSessionId: 'session-1',
|
||||
sourceMessageId: 'message-2',
|
||||
});
|
||||
assert.equal(updated.updated, 1);
|
||||
assert.equal(state.versions.length, 1);
|
||||
assert.equal(state.assets[0].size_bytes, Buffer.byteLength('updated workspace\n'));
|
||||
});
|
||||
|
||||
test('syncUserWorkspace imports sandboxable public html as warned ready asset', async () => {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
createAgentRun,
|
||||
createReporter,
|
||||
extractAssistantTexts,
|
||||
getSession,
|
||||
loginViaApi,
|
||||
logoutViaApi,
|
||||
waitForRunTerminal,
|
||||
} from './scenario-test-lib.mjs';
|
||||
|
||||
const baseUrl = process.env.MEMIND_E2E_BASE_URL ?? 'http://127.0.0.1:8081';
|
||||
const username = process.env.MEMIND_E2E_USERNAME ?? 'john4';
|
||||
const password = process.env.MEMIND_E2E_PASSWORD ?? '888888';
|
||||
const userId = process.env.MEMIND_E2E_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
|
||||
const pageName = process.env.MEMIND_E2E_PAGE_NAME ?? 'durability-survey-20260727.html';
|
||||
const pageUrl = `${baseUrl}/MindSpace/${userId}/public/${pageName}`;
|
||||
const timeoutMs = Number(process.env.MEMIND_E2E_RUN_TIMEOUT_MS ?? 180_000);
|
||||
const startTurn = Math.max(
|
||||
1,
|
||||
Math.min(21, Number(process.env.MEMIND_E2E_START_TURN ?? 1) || 1),
|
||||
);
|
||||
const resumeSessionId = String(process.env.MEMIND_E2E_RESUME_SESSION_ID ?? '').trim() || null;
|
||||
|
||||
const editTurns = [
|
||||
'创建一个单页“小问卷耐久测试系统”:包含标题、昵称输入、一个基础单选问题、提交按钮和页内结果区;答案只保存在当前页面内存中,不做网络提交。创建可见的 <details><ol id="durability-log"></ol></details> 验收记录。',
|
||||
'新增“年龄段”下拉选择题,并在结果区显示对应中文标签。',
|
||||
'新增“最喜欢的活动”多选题,至少包含阅读、运动、音乐和旅行。',
|
||||
'新增“每周参与频率”单选题,包含偶尔、每周、每天。',
|
||||
'新增“你最期待的改进”多行文本题,并限制为 200 字。',
|
||||
'给必填题增加页内校验和 role="alert" 错误提示,不允许用浏览器弹窗替代。',
|
||||
'新增实时答题进度条,按已回答题目数量更新百分比。',
|
||||
'把问卷改成两步填写,加入上一步和下一步按钮;最后一步才能提交。',
|
||||
'提交前增加答案复核区域,可返回修改后再确认。',
|
||||
'把视觉主题调整为蓝绿色卡片风格,保持现有功能和数据结构。',
|
||||
'增加 prefers-color-scheme 深色模式样式,但不要持久化主题状态。',
|
||||
'优化手机窄屏布局,按钮在小屏幕下堆叠且可触摸区域不小于 44px。',
|
||||
'完善可访问性:题组使用 fieldset/legend,输入与标签关联,并提供清晰的 :focus-visible。',
|
||||
'新增“重新填写”按钮,重置表单、进度、复核区和结果区。',
|
||||
'提交成功后显示温和的页内完成提示,不使用 alert。',
|
||||
'在完成结果中显示本次提交的本地时间,刷新页面后不需要保留。',
|
||||
'结果区按题目中文名称逐项展示答案,不能只输出原始字段名。',
|
||||
'新增“打印结果”按钮,调用 window.print(),并添加简洁的 @media print 样式。',
|
||||
'新增问卷版本徽标 v1.0 和“最后更新:耐久测试第 19 轮”文案。',
|
||||
'新增键盘友好的跳过链接,可直接跳到问卷正文和结果区域。',
|
||||
'做最终一致性整理:保留全部问题、两步导航、校验、复核、重置、打印、深色模式和移动端样式,并在页脚写“21 轮持续修改验收完成”。',
|
||||
];
|
||||
|
||||
const phaseEnds = new Set([6, 12, 18]);
|
||||
const statusAfter = new Map([
|
||||
[6, 'STATUS-AFTER-06'],
|
||||
[12, 'STATUS-AFTER-12'],
|
||||
[18, 'STATUS-AFTER-18'],
|
||||
]);
|
||||
const completedEditCodes = [];
|
||||
const runRecords = [];
|
||||
const reporter = createReporter();
|
||||
let auth = null;
|
||||
let sessionId = resumeSessionId;
|
||||
let loginCount = 0;
|
||||
|
||||
for (let turnNumber = 1; turnNumber < startTurn; turnNumber += 1) {
|
||||
completedEditCodes.push({
|
||||
code: turnCode(turnNumber),
|
||||
marker: turnMarker(turnNumber),
|
||||
});
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function hashText(text) {
|
||||
return crypto.createHash('sha256').update(text).digest('hex');
|
||||
}
|
||||
|
||||
function turnCode(turnNumber) {
|
||||
return `DURABILITY-${String(turnNumber).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function turnMarker(turnNumber) {
|
||||
return `durability-turn-${String(turnNumber).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function login(label) {
|
||||
auth = await loginViaApi(baseUrl, { username, password }, reporter);
|
||||
loginCount += 1;
|
||||
console.log(`[auth] ${label}: login #${loginCount}`);
|
||||
if (sessionId) {
|
||||
const result = await getSession(baseUrl, auth.cookie, sessionId);
|
||||
assert(result.ok, `[auth] ${label}: session ${sessionId} unavailable after login`);
|
||||
const transcript = extractAssistantTexts(result.session).join('\n');
|
||||
const expected = completedEditCodes.at(-1)?.code;
|
||||
if (expected) {
|
||||
assert(
|
||||
transcript.includes(expected),
|
||||
`[auth] ${label}: restored transcript is missing ${expected}`,
|
||||
);
|
||||
}
|
||||
console.log(`[auth] ${label}: restored session ${sessionId} with ${expected ?? 'no prior code'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(label) {
|
||||
assert(auth?.cookie, `[auth] ${label}: no active login`);
|
||||
await logoutViaApi(baseUrl, auth.cookie, reporter);
|
||||
auth = null;
|
||||
console.log(`[auth] ${label}: logout complete`);
|
||||
}
|
||||
|
||||
async function fetchPage() {
|
||||
const response = await fetch(pageUrl, {
|
||||
headers: auth?.cookie ? { Cookie: auth.cookie } : {},
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok, `page ${pageUrl} returned ${response.status}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function assertSessionReply(expectedCode) {
|
||||
const result = await getSession(baseUrl, auth.cookie, sessionId);
|
||||
assert(result.ok, `session ${sessionId} returned ${result.status}`);
|
||||
const assistantTexts = extractAssistantTexts(result.session);
|
||||
const transcript = assistantTexts.join('\n');
|
||||
assert(
|
||||
transcript.includes(expectedCode),
|
||||
`session ${sessionId} assistant transcript is missing ${expectedCode}`,
|
||||
);
|
||||
return {
|
||||
assistantCount: assistantTexts.length,
|
||||
transcriptLength: transcript.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEditPrompt(turnNumber, instruction) {
|
||||
const code = turnCode(turnNumber);
|
||||
const marker = turnMarker(turnNumber);
|
||||
if (turnNumber === 1) {
|
||||
return [
|
||||
`这是持续对话耐久测试第 ${turnNumber}/21 轮。`,
|
||||
`请创建 public/${pageName}。${instruction}`,
|
||||
`在 #durability-log 末尾加入 <li id="${marker}">${code}</li>。`,
|
||||
'禁止调用任何图片工具;禁止使用 localStorage、sessionStorage、IndexedDB 或 Cookie。',
|
||||
`最终回复必须包含验收码 ${code} 和页面链接。`,
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`这是持续对话耐久测试第 ${turnNumber}/21 轮。`,
|
||||
`继续修改同一个 public/${pageName},不要新建页面。`,
|
||||
'必须保留之前所有功能与所有 durability-turn-* 验收标记。',
|
||||
instruction,
|
||||
`在 #durability-log 末尾加入 <li id="${marker}">${code}</li>。`,
|
||||
'禁止调用任何图片工具;禁止使用 localStorage、sessionStorage、IndexedDB 或 Cookie。',
|
||||
`最终回复必须包含验收码 ${code} 和页面链接。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function createRunWithOptionalConflict(turnNumber, message, { probeConflict = false } = {}) {
|
||||
const created = await createAgentRun(baseUrl, auth.cookie, {
|
||||
message,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (probeConflict) {
|
||||
let conflictError = null;
|
||||
try {
|
||||
await createAgentRun(baseUrl, auth.cookie, {
|
||||
message: `并发探针 ${turnCode(turnNumber)}:这条请求必须被同会话并发保护拒绝。`,
|
||||
sessionId: created.sessionId ?? sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
conflictError = error;
|
||||
}
|
||||
const conflictText = String(conflictError?.message ?? '');
|
||||
assert(
|
||||
/409/.test(conflictText) && /SESSION_RUN_CONFLICT/.test(conflictText),
|
||||
`turn ${turnNumber}: expected SESSION_RUN_CONFLICT, got ${conflictText || 'no error'}`,
|
||||
);
|
||||
console.log(`[conflict] turn ${turnNumber}: expected 409 SESSION_RUN_CONFLICT`);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function runEditTurn(turnNumber, instruction, options = {}) {
|
||||
const code = turnCode(turnNumber);
|
||||
const marker = turnMarker(turnNumber);
|
||||
const startedAt = Date.now();
|
||||
const created = await createRunWithOptionalConflict(
|
||||
turnNumber,
|
||||
buildEditPrompt(turnNumber, instruction),
|
||||
{ probeConflict: options.probeConflict },
|
||||
);
|
||||
|
||||
if (options.logoutDuringRun) {
|
||||
await logout(`turn ${turnNumber} active`);
|
||||
await login(`turn ${turnNumber} resume`);
|
||||
}
|
||||
|
||||
const terminal = await waitForRunTerminal(baseUrl, auth.cookie, created.runId, timeoutMs);
|
||||
assert(
|
||||
terminal.status === 'succeeded',
|
||||
`turn ${turnNumber}: run ${created.runId} ended ${terminal.status}: ${terminal.error_code ?? terminal.error_message ?? ''}`,
|
||||
);
|
||||
sessionId =
|
||||
terminal.agent_session_id
|
||||
?? terminal.sessionId
|
||||
?? created.sessionId
|
||||
?? sessionId;
|
||||
assert(sessionId, `turn ${turnNumber}: terminal run has no session id`);
|
||||
|
||||
const page = await fetchPage();
|
||||
const expectedMarkers = [...completedEditCodes.map((entry) => entry.marker), marker];
|
||||
for (const expectedMarker of expectedMarkers) {
|
||||
assert(
|
||||
page.includes(`id="${expectedMarker}"`) || page.includes(`id='${expectedMarker}'`),
|
||||
`turn ${turnNumber}: page lost marker ${expectedMarker}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!/\b(?:localStorage|sessionStorage|indexedDB)\b/.test(page),
|
||||
`turn ${turnNumber}: page introduced forbidden browser storage`,
|
||||
);
|
||||
const sessionStats = await assertSessionReply(code);
|
||||
completedEditCodes.push({ code, marker });
|
||||
const record = {
|
||||
turnNumber,
|
||||
code,
|
||||
runId: created.runId,
|
||||
sessionId,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
pageBytes: Buffer.byteLength(page),
|
||||
pageHash: hashText(page),
|
||||
...sessionStats,
|
||||
};
|
||||
runRecords.push(record);
|
||||
console.log(
|
||||
`[turn ${String(turnNumber).padStart(2, '0')}/21] ${code} ok`
|
||||
+ ` run=${created.runId} session=${sessionId}`
|
||||
+ ` elapsed=${Math.round(record.elapsedMs / 1000)}s page=${record.pageBytes}B`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runStatusTurn(afterTurn, expectedCode) {
|
||||
const before = await fetchPage();
|
||||
const created = await createAgentRun(baseUrl, auth.cookie, {
|
||||
sessionId,
|
||||
message: [
|
||||
`这是第 ${afterTurn} 轮修改后的状态追问。`,
|
||||
`请只汇报 public/${pageName} 当前已完成到 ${turnCode(afterTurn)},不要修改任何文件。`,
|
||||
`最终回复必须包含状态验收码 ${expectedCode} 和页面链接。`,
|
||||
].join('\n'),
|
||||
});
|
||||
const terminal = await waitForRunTerminal(baseUrl, auth.cookie, created.runId, timeoutMs);
|
||||
assert(terminal.status === 'succeeded', `${expectedCode}: status run ended ${terminal.status}`);
|
||||
sessionId = terminal.agent_session_id ?? terminal.sessionId ?? created.sessionId ?? sessionId;
|
||||
const sessionStats = await assertSessionReply(expectedCode);
|
||||
const after = await fetchPage();
|
||||
assert(
|
||||
hashText(after) === hashText(before),
|
||||
`${expectedCode}: status-only follow-up unexpectedly changed the page`,
|
||||
);
|
||||
runRecords.push({
|
||||
statusCode: expectedCode,
|
||||
runId: created.runId,
|
||||
sessionId,
|
||||
pageHash: hashText(after),
|
||||
...sessionStats,
|
||||
});
|
||||
console.log(`[status] ${expectedCode} ok; page hash unchanged`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Long conversation continuity: ${pageName} ===`);
|
||||
await login('initial');
|
||||
|
||||
for (let index = startTurn - 1; index < editTurns.length; index += 1) {
|
||||
const turnNumber = index + 1;
|
||||
await runEditTurn(turnNumber, editTurns[index], {
|
||||
logoutDuringRun: turnNumber === 13,
|
||||
probeConflict: turnNumber === 17,
|
||||
});
|
||||
|
||||
const statusCode = statusAfter.get(turnNumber);
|
||||
if (statusCode) await runStatusTurn(turnNumber, statusCode);
|
||||
|
||||
if (phaseEnds.has(turnNumber)) {
|
||||
await logout(`phase after turn ${turnNumber}`);
|
||||
await login(`phase after turn ${turnNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logout('final persistence check');
|
||||
await login('final persistence check');
|
||||
const finalPage = await fetchPage();
|
||||
const finalTranscript = await assertSessionReply(turnCode(editTurns.length));
|
||||
for (const { marker } of completedEditCodes) {
|
||||
assert(
|
||||
finalPage.includes(`id="${marker}"`) || finalPage.includes(`id='${marker}'`),
|
||||
`final page lost ${marker}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
finalPage.includes('21 轮持续修改验收完成'),
|
||||
'final page is missing the 21-turn completion footer',
|
||||
);
|
||||
await logout('complete');
|
||||
|
||||
console.log('\n=== RESULT ===');
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
startTurn,
|
||||
editTurns: completedEditCodes.length,
|
||||
statusTurns: runRecords.filter((record) => record.statusCode).length,
|
||||
totalConversationTurns: runRecords.length,
|
||||
loginCount,
|
||||
sessionId,
|
||||
pageUrl,
|
||||
finalPageBytes: Buffer.byteLength(finalPage),
|
||||
finalPageHash: hashText(finalPage),
|
||||
finalTranscript,
|
||||
runs: runRecords,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(`\nLONG_CONVERSATION_CONTINUITY_FAILED: ${error?.stack ?? error}`);
|
||||
if (auth?.cookie) {
|
||||
try {
|
||||
await logout('failure cleanup');
|
||||
} catch (logoutError) {
|
||||
console.error(`failure cleanup logout failed: ${logoutError?.message ?? logoutError}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -85,6 +85,11 @@ export function attachPortalSessionRoutes(
|
||||
maybeRepairPageDataAfterFinish,
|
||||
markPageDeliveryContractReadyFn =
|
||||
markPageDeliveryContractReady,
|
||||
finishDeliveryRetryDelaysMs = [250, 1_000],
|
||||
finishDeliveryRetryWaitFn = (delayMs) =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(resolve, delayMs),
|
||||
),
|
||||
logger = console,
|
||||
} = {},
|
||||
) {
|
||||
@@ -481,7 +486,7 @@ export function attachPortalSessionRoutes(
|
||||
// After Finish, refresh the snapshot and persist any newly generated public
|
||||
// workspace HTML into the asset store before a later restart rebuilds the
|
||||
// workspace from DB-backed assets only.
|
||||
const onAfterFinish = async (sid, uid) => {
|
||||
const finalizeAfterFinishOnce = async (sid, uid) => {
|
||||
beginSessionPageDelivery(sid);
|
||||
try {
|
||||
const apiFetchFn = async (pathname, init) => {
|
||||
@@ -614,23 +619,33 @@ export function attachPortalSessionRoutes(
|
||||
].includes(
|
||||
String(pageDataDelivery?.skipped ?? ''),
|
||||
);
|
||||
if (htmlReady && pageDataReady) {
|
||||
const publicHtmlRelativePaths =
|
||||
[
|
||||
...new Set([
|
||||
...(syncResult?.publicHtmlRelativePaths ??
|
||||
[]),
|
||||
...deliveryContractWrites.keys(),
|
||||
]),
|
||||
].sort();
|
||||
const pgRequired = [
|
||||
...(Array.isArray(messages) ? messages : []),
|
||||
].some(
|
||||
(message) =>
|
||||
message?.role === 'user' &&
|
||||
message?.metadata?.memindRun?.pgRequired ===
|
||||
true,
|
||||
const publicHtmlRelativePaths =
|
||||
[
|
||||
...new Set([
|
||||
...(syncResult?.publicHtmlRelativePaths ??
|
||||
[]),
|
||||
...deliveryContractWrites.keys(),
|
||||
]),
|
||||
].sort();
|
||||
const pgRequired = [
|
||||
...(Array.isArray(messages) ? messages : []),
|
||||
].some(
|
||||
(message) =>
|
||||
message?.role === 'user' &&
|
||||
message?.metadata?.memindRun?.pgRequired ===
|
||||
true,
|
||||
);
|
||||
if (
|
||||
publicHtmlRelativePaths.length > 0 &&
|
||||
(!htmlReady || !pageDataReady)
|
||||
) {
|
||||
throw new Error(
|
||||
'page delivery guards are not ready: '
|
||||
+ `html=${htmlDelivery?.skipped ?? 'unknown'} `
|
||||
+ `pageData=${pageDataDelivery?.skipped ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
if (htmlReady && pageDataReady) {
|
||||
for (const relativePath of publicHtmlRelativePaths) {
|
||||
// A Finish-only write may not have reached the stream callback. This
|
||||
// also upgrades an early partial stream contract with the definitive
|
||||
@@ -684,6 +699,42 @@ export function attachPortalSessionRoutes(
|
||||
endSessionPageDelivery(sid);
|
||||
}
|
||||
};
|
||||
const onAfterFinish = async (sid, uid) => {
|
||||
const retryDelays = Array.isArray(
|
||||
finishDeliveryRetryDelaysMs,
|
||||
)
|
||||
? finishDeliveryRetryDelaysMs
|
||||
.map((delayMs) =>
|
||||
Math.max(0, Number(delayMs) || 0),
|
||||
)
|
||||
: [];
|
||||
let lastError = null;
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt <= retryDelays.length;
|
||||
attempt += 1
|
||||
) {
|
||||
if (attempt > 0) {
|
||||
await finishDeliveryRetryWaitFn(
|
||||
retryDelays[attempt - 1],
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await finalizeAfterFinishOnce(
|
||||
sid,
|
||||
uid,
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn(
|
||||
`[MindSpace] Finish delivery finalization failed for session ${sid} `
|
||||
+ `(attempt ${attempt + 1}/${retryDelays.length + 1}): `
|
||||
+ `${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
return tkmindProxy.proxySessionEvents(
|
||||
req,
|
||||
res,
|
||||
|
||||
@@ -808,3 +808,154 @@ test('Finish marks streamed HTML contracts ready when final sync omits one path'
|
||||
'public/shop.html',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Finish retries delivery finalization after a transient post-Finish failure', async () => {
|
||||
let hooks = null;
|
||||
let syncPageAttempts = 0;
|
||||
const readyPaths = [];
|
||||
const warnings = [];
|
||||
const setup = createDependencies({
|
||||
finishDeliveryRetryDelaysMs: [0],
|
||||
finishDeliveryRetryWaitFn: async () => {},
|
||||
getTkmindProxy: () => ({
|
||||
async resolveTarget(sessionId) {
|
||||
return `target:${sessionId}`;
|
||||
},
|
||||
async apiFetchTo() {
|
||||
return createUpstream();
|
||||
},
|
||||
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
|
||||
hooks = receivedHooks;
|
||||
},
|
||||
}),
|
||||
getMindSpacePublicFinish: () => ({
|
||||
async materializeSessionEvent() {
|
||||
return {
|
||||
publicHtmlRelativePaths: [],
|
||||
publicHtmlArtifacts: [],
|
||||
};
|
||||
},
|
||||
async syncAfterFinish() {
|
||||
return {
|
||||
publicHtmlRelativePaths: ['public/survey.html'],
|
||||
docxSync: { missing: [] },
|
||||
};
|
||||
},
|
||||
async preparePageDataAfterFinish() {
|
||||
return {
|
||||
autoBind: {
|
||||
bound: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
evaluation: {
|
||||
structuralPageData: false,
|
||||
relevantFiles: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
async syncUserGeneratedPages() {
|
||||
syncPageAttempts += 1;
|
||||
if (syncPageAttempts === 1) {
|
||||
throw new Error('temporary page sync failure');
|
||||
}
|
||||
},
|
||||
async markPageDeliveryContractReadyFn(input) {
|
||||
readyPaths.push(input.relativePath);
|
||||
return true;
|
||||
},
|
||||
logger: {
|
||||
warn(...items) {
|
||||
warnings.push(items.join(' '));
|
||||
},
|
||||
},
|
||||
});
|
||||
const api = createRouterRecorder();
|
||||
attachPortalSessionRoutes(api, setup.dependencies);
|
||||
await api.routes.get('GET /sessions/:sessionId/events')(
|
||||
createRequest(),
|
||||
createResponseRecorder(),
|
||||
() => {},
|
||||
);
|
||||
|
||||
await hooks.onAfterFinish('session-1', 'user-1');
|
||||
|
||||
assert.equal(syncPageAttempts, 2);
|
||||
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
||||
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
|
||||
assert.deepEqual(readyPaths, ['public/survey.html']);
|
||||
assert.match(warnings[0], /temporary page sync failure/);
|
||||
});
|
||||
|
||||
test('Finish retries when a delivery guard is initially not ready', async () => {
|
||||
let hooks = null;
|
||||
let pageDataChecks = 0;
|
||||
const readyPaths = [];
|
||||
const setup = createDependencies({
|
||||
finishDeliveryRetryDelaysMs: [0],
|
||||
finishDeliveryRetryWaitFn: async () => {},
|
||||
getTkmindProxy: () => ({
|
||||
async resolveTarget(sessionId) {
|
||||
return `target:${sessionId}`;
|
||||
},
|
||||
async apiFetchTo() {
|
||||
return createUpstream();
|
||||
},
|
||||
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
|
||||
hooks = receivedHooks;
|
||||
},
|
||||
}),
|
||||
getMindSpacePublicFinish: () => ({
|
||||
async materializeSessionEvent() {
|
||||
return {
|
||||
publicHtmlRelativePaths: [],
|
||||
publicHtmlArtifacts: [],
|
||||
};
|
||||
},
|
||||
async syncAfterFinish() {
|
||||
return {
|
||||
publicHtmlRelativePaths: ['public/survey.html'],
|
||||
docxSync: { missing: [] },
|
||||
};
|
||||
},
|
||||
async preparePageDataAfterFinish() {
|
||||
return {
|
||||
autoBind: {
|
||||
bound: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
evaluation: {
|
||||
structuralPageData: true,
|
||||
relevantFiles: [{ relativePath: 'public/survey.html' }],
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
async maybeRepairPageDataAfterFinishFn() {
|
||||
pageDataChecks += 1;
|
||||
return {
|
||||
skipped: pageDataChecks === 1 ? 'limit' : 'ok',
|
||||
};
|
||||
},
|
||||
async markPageDeliveryContractReadyFn(input) {
|
||||
readyPaths.push(input.relativePath);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const api = createRouterRecorder();
|
||||
attachPortalSessionRoutes(api, setup.dependencies);
|
||||
await api.routes.get('GET /sessions/:sessionId/events')(
|
||||
createRequest(),
|
||||
createResponseRecorder(),
|
||||
() => {},
|
||||
);
|
||||
|
||||
await hooks.onAfterFinish('session-1', 'user-1');
|
||||
|
||||
assert.equal(pageDataChecks, 2);
|
||||
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
||||
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
|
||||
assert.deepEqual(readyPaths, ['public/survey.html']);
|
||||
});
|
||||
|
||||
+88
-24
@@ -145,6 +145,8 @@ export async function consumeSessionEventsUntilFinish(
|
||||
requestId = null,
|
||||
timeoutMs = 15 * 60 * 1000,
|
||||
onEvent = null,
|
||||
requireRequestActivityBeforeUnscopedFinish = false,
|
||||
emptyFinishGraceMs = 20_000,
|
||||
} = {},
|
||||
) {
|
||||
if (!body) {
|
||||
@@ -157,44 +159,106 @@ export async function consumeSessionEventsUntilFinish(
|
||||
const decoder = new TextDecoder();
|
||||
const toolEvidence = createToolEvidenceCollector();
|
||||
let pendingProviderError = null;
|
||||
let sawRequestScopedActivity = false;
|
||||
let buffer = '';
|
||||
const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 1);
|
||||
let timeoutHandle = null;
|
||||
|
||||
for await (const chunk of reader) {
|
||||
if (Date.now() > deadline) {
|
||||
const err = new Error(`session reply timed out after ${timeoutMs}ms`);
|
||||
err.code = 'SESSION_REPLY_TIMEOUT';
|
||||
err.retryable = false;
|
||||
throw err;
|
||||
}
|
||||
const armTimeout = (delayMs, { code, message }) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
error.retryable = false;
|
||||
reader.destroy(error);
|
||||
}, Math.max(1, delayMs));
|
||||
};
|
||||
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
const trimmed = frame.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
const event = parseSessionStreamEvent(trimmed);
|
||||
if (!event) continue;
|
||||
if (!eventMatchesRequest(event, requestId)) continue;
|
||||
pendingProviderError = providerReplyError(event) ?? pendingProviderError;
|
||||
toolEvidence.observe(event);
|
||||
onEvent?.(event);
|
||||
if (event.type === 'Error') {
|
||||
const err = new Error(String(event.error ?? 'session reply failed'));
|
||||
err.code = classifySessionProviderErrorMessage(err.message);
|
||||
const armOriginalDeadline = () => {
|
||||
armTimeout(Math.max(1, deadline - Date.now()), {
|
||||
code: 'SESSION_REPLY_TIMEOUT',
|
||||
message: `session reply timed out after ${timeoutMs}ms`,
|
||||
});
|
||||
};
|
||||
|
||||
armOriginalDeadline();
|
||||
|
||||
try {
|
||||
for await (const chunk of reader) {
|
||||
if (Date.now() > deadline) {
|
||||
const err = new Error(`session reply timed out after ${timeoutMs}ms`);
|
||||
err.code = 'SESSION_REPLY_TIMEOUT';
|
||||
err.retryable = false;
|
||||
throw err;
|
||||
}
|
||||
if (event.type === 'Finish') {
|
||||
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
const trimmed = frame.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
const event = parseSessionStreamEvent(trimmed);
|
||||
if (!event) continue;
|
||||
if (!eventMatchesRequest(event, requestId)) continue;
|
||||
const routingId = event?.chat_request_id ?? event?.request_id ?? null;
|
||||
if (requestId && routingId === requestId) {
|
||||
sawRequestScopedActivity = true;
|
||||
}
|
||||
pendingProviderError = providerReplyError(event) ?? pendingProviderError;
|
||||
toolEvidence.observe(event);
|
||||
onEvent?.(event);
|
||||
if (event.type === 'Error') {
|
||||
const err = new Error(String(event.error ?? 'session reply failed'));
|
||||
err.code = classifySessionProviderErrorMessage(err.message);
|
||||
err.retryable = false;
|
||||
throw err;
|
||||
}
|
||||
if (event.type === 'Message') {
|
||||
armOriginalDeadline();
|
||||
}
|
||||
if (event.type !== 'Finish') continue;
|
||||
|
||||
const tokenState = event.token_state ?? null;
|
||||
const tokenActivity = [
|
||||
tokenState?.inputTokens,
|
||||
tokenState?.outputTokens,
|
||||
tokenState?.totalTokens,
|
||||
tokenState?.accumulatedInputTokens,
|
||||
tokenState?.accumulatedOutputTokens,
|
||||
tokenState?.accumulatedTotalTokens,
|
||||
].some((value) => Number(value) > 0);
|
||||
const toolActivity = toolEvidence.snapshot().calls.length > 0;
|
||||
if (
|
||||
requireRequestActivityBeforeUnscopedFinish
|
||||
&& requestId
|
||||
&& (
|
||||
(!routingId && !sawRequestScopedActivity)
|
||||
|| (!tokenActivity && !toolActivity)
|
||||
)
|
||||
) {
|
||||
armTimeout(
|
||||
Math.min(
|
||||
Math.max(1, deadline - Date.now()),
|
||||
Math.max(1, Number(emptyFinishGraceMs) || 1),
|
||||
),
|
||||
{
|
||||
code: 'SESSION_EMPTY_FINISH',
|
||||
message: 'session emitted an empty Finish without model or tool activity',
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pendingProviderError) throw pendingProviderError;
|
||||
return {
|
||||
finishEvent: event,
|
||||
tokenState: event.token_state ?? null,
|
||||
tokenState,
|
||||
toolEvidence: toolEvidence.snapshot(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
const err = new Error('session event stream ended before Finish');
|
||||
|
||||
@@ -35,6 +35,53 @@ test('consumeSessionEventsUntilFinish resolves on Finish', async () => {
|
||||
assert.equal(result.tokenState.totalTokens, 12);
|
||||
});
|
||||
|
||||
test('consumeSessionEventsUntilFinish ignores a stale unscoped Finish before request activity', async () => {
|
||||
const frames = [
|
||||
'data: {"type":"Finish","token_state":{"totalTokens":0}}\n\n',
|
||||
'data: {"type":"ActiveRequests","request_id":"req-fresh","request_ids":["req-fresh"]}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-fresh","token_state":{"totalTokens":0}}\n\n',
|
||||
'data: {"type":"Message","request_id":"req-fresh","message":{"role":"assistant"}}\n\n',
|
||||
'data: {"type":"Finish","token_state":{"totalTokens":12}}\n\n',
|
||||
];
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const result = await consumeSessionEventsUntilFinish(stream, {
|
||||
requestId: 'req-fresh',
|
||||
timeoutMs: 5000,
|
||||
requireRequestActivityBeforeUnscopedFinish: true,
|
||||
});
|
||||
|
||||
assert.equal(result.tokenState.totalTokens, 12);
|
||||
});
|
||||
|
||||
test('consumeSessionEventsUntilFinish fails fast when an empty Finish has no later activity', async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(
|
||||
'data: {"type":"Finish","request_id":"req-empty","token_state":{"totalTokens":0}}\n\n',
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
consumeSessionEventsUntilFinish(stream, {
|
||||
requestId: 'req-empty',
|
||||
timeoutMs: 5000,
|
||||
emptyFinishGraceMs: 5,
|
||||
requireRequestActivityBeforeUnscopedFinish: true,
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'SESSION_EMPTY_FINISH');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('consumeSessionEventsUntilFinish records a successful raster generate_image result', async () => {
|
||||
const toolResult = JSON.stringify({
|
||||
ok: true,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../utils/imageUpload';
|
||||
import { buildAbsoluteAssetDownloadUrl } from '../utils/mindspaceCards';
|
||||
import { resolveAgentRunOptions } from '../utils/agentRunMode';
|
||||
import { shouldIgnoreZeroActivityFinish } from '../../chat-agent-run-gate.mjs';
|
||||
import {
|
||||
buildUserMessage,
|
||||
normalizeConversationMessages,
|
||||
@@ -183,6 +184,13 @@ export function usePageEditSubChat({
|
||||
activeRequestId.current = null;
|
||||
return;
|
||||
case 'Finish':
|
||||
if (shouldIgnoreZeroActivityFinish({
|
||||
eventType: event.type,
|
||||
tokenState: event.token_state,
|
||||
hasActiveRequest: Boolean(activeRequestId.current),
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
setChatState('idle');
|
||||
setPendingTool(null);
|
||||
activeRequestId.current = null;
|
||||
|
||||
@@ -58,8 +58,10 @@ import {
|
||||
import {
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldIgnoreZeroActivityFinish,
|
||||
shouldKeepStreamingAfterRunError,
|
||||
shouldPromoteSessionIdToStreaming,
|
||||
shouldScheduleMissingActiveRequestGrace,
|
||||
} from '../../chat-agent-run-gate.mjs';
|
||||
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
|
||||
import {
|
||||
@@ -372,6 +374,7 @@ export function useTKMindChat(
|
||||
const seenNotificationIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const activeRequestId = useRef<string | null>(null);
|
||||
const agentRunPendingRef = useRef(false);
|
||||
const activeRequestMissingTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const chatStateRef = useRef<ChatState>(chatState);
|
||||
const subscribedSessionIdRef = useRef<string | null>(null);
|
||||
@@ -930,14 +933,18 @@ export function useTKMindChat(
|
||||
activeRequestId.current = retryRequestId;
|
||||
setChatState('waiting');
|
||||
setError(null);
|
||||
clearActiveRequestMissingTimer();
|
||||
agentRunPendingRef.current = true;
|
||||
const retryRun = await createAgentRun(sessionRef.current!.id, retryRequestId, lastUser);
|
||||
const finishedRetryRun =
|
||||
retryRun.status === 'succeeded' ? retryRun : await waitForAgentRun(retryRun.id);
|
||||
agentRunPendingRef.current = false;
|
||||
if (!finishedRetryRun.sessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
setChatState('streaming');
|
||||
} catch (err) {
|
||||
agentRunPendingRef.current = false;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setChatState('error');
|
||||
activeRequestId.current = null;
|
||||
@@ -950,6 +957,7 @@ export function useTKMindChat(
|
||||
: '云端 Relay 不可用,且本地 fallback 重试失败或未启用',
|
||||
);
|
||||
setChatState('error');
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
}
|
||||
return;
|
||||
@@ -986,11 +994,20 @@ export function useTKMindChat(
|
||||
case 'Error':
|
||||
setError(event.error);
|
||||
setChatState('error');
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
return;
|
||||
case 'Finish':
|
||||
if (shouldIgnoreZeroActivityFinish({
|
||||
eventType: event.type,
|
||||
tokenState: event.token_state,
|
||||
hasActiveRequest: Boolean(activeRequestId.current),
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
setChatState('idle');
|
||||
setPendingTool(null);
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
if (isDirectChatSessionId(sessionId)) {
|
||||
unsubscribeRef.current?.();
|
||||
@@ -1054,7 +1071,12 @@ export function useTKMindChat(
|
||||
rid = activeRequestContext.activeRequestId;
|
||||
} else if (rid && event.request_ids.includes(rid)) {
|
||||
clearActiveRequestMissingTimer();
|
||||
} else if (activeRequestContext.allowMissingGrace) {
|
||||
} else if (
|
||||
shouldScheduleMissingActiveRequestGrace({
|
||||
allowMissingGrace: activeRequestContext.allowMissingGrace,
|
||||
agentRunPending: agentRunPendingRef.current,
|
||||
})
|
||||
) {
|
||||
// The backend can briefly report no active request between tool phases.
|
||||
// Confirm the absence before turning the UI idle, otherwise MindSpace
|
||||
// refreshes the page while tools are still mutating it.
|
||||
@@ -1062,6 +1084,7 @@ export function useTKMindChat(
|
||||
activeRequestMissingTimerRef.current = window.setTimeout(() => {
|
||||
activeRequestMissingTimerRef.current = null;
|
||||
if (activeRequestId.current !== rid) return;
|
||||
if (agentRunPendingRef.current) return;
|
||||
activeRequestId.current = null;
|
||||
setChatState('idle');
|
||||
setPendingTool(null);
|
||||
@@ -1139,6 +1162,7 @@ export function useTKMindChat(
|
||||
clearActiveRequestMissingTimer();
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
messagesRef.current = [];
|
||||
messageHistoryLoadedCountRef.current = 0;
|
||||
@@ -1173,6 +1197,7 @@ export function useTKMindChat(
|
||||
}
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
|
||||
let completed = false;
|
||||
@@ -1393,6 +1418,7 @@ export function useTKMindChat(
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
clearActiveRequestMissingTimer();
|
||||
agentRunPendingRef.current = false;
|
||||
};
|
||||
// Re-run when the signed-in user changes so session storage stays per-user.
|
||||
}, [clearActiveRequestMissingTimer, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]);
|
||||
@@ -1529,6 +1555,8 @@ export function useTKMindChat(
|
||||
requestId,
|
||||
mindspaceContext: options?.mindspaceContext ?? null,
|
||||
});
|
||||
clearActiveRequestMissingTimer();
|
||||
agentRunPendingRef.current = true;
|
||||
const createdRun = await createAgentRun(
|
||||
activeSessionId,
|
||||
requestId,
|
||||
@@ -1587,6 +1615,7 @@ export function useTKMindChat(
|
||||
},
|
||||
});
|
||||
if (submitToken !== connectTokenRef.current) return;
|
||||
agentRunPendingRef.current = false;
|
||||
activeSessionId = finishedRun.sessionId;
|
||||
if (!activeSessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
@@ -1697,6 +1726,7 @@ export function useTKMindChat(
|
||||
scheduleReplyRecoverySync(activeSessionId, submitToken);
|
||||
return;
|
||||
}
|
||||
agentRunPendingRef.current = false;
|
||||
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
notifyInsufficientBalance();
|
||||
@@ -1728,6 +1758,7 @@ export function useTKMindChat(
|
||||
await cancelRequest(session.id, activeRequestId.current);
|
||||
} finally {
|
||||
clearActiveRequestMissingTimer();
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
setChatState('idle');
|
||||
}
|
||||
@@ -1762,6 +1793,7 @@ export function useTKMindChat(
|
||||
subscribedSessionIdRef.current = null;
|
||||
clearActiveRequestMissingTimer();
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
messagesRef.current = [];
|
||||
messageHistoryLoadedCountRef.current = 0;
|
||||
|
||||
+143
-5
@@ -1737,6 +1737,97 @@ export function createTkmindProxy({
|
||||
return Array.isArray(session?.conversation) ? session.conversation : [];
|
||||
}
|
||||
|
||||
async function compactSessionConversationForUser(
|
||||
userId,
|
||||
sessionId,
|
||||
{
|
||||
messageThreshold = 180,
|
||||
charThreshold = 120_000,
|
||||
} = {},
|
||||
) {
|
||||
if (!userId || !sessionId) {
|
||||
return { compacted: false, reason: 'missing_session', conversation: [] };
|
||||
}
|
||||
const owns = await sessionStore.validateOwnership(userId, sessionId);
|
||||
if (!owns) {
|
||||
return { compacted: false, reason: 'not_owned', conversation: [] };
|
||||
}
|
||||
const target = await resolveTarget(sessionId);
|
||||
const upstream = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
return {
|
||||
compacted: false,
|
||||
reason: 'session_unavailable',
|
||||
status: upstream.status,
|
||||
conversation: [],
|
||||
};
|
||||
}
|
||||
const session = await upstream.json().catch(() => null);
|
||||
const conversation = Array.isArray(session?.conversation) ? session.conversation : [];
|
||||
const messageCount = conversation.length;
|
||||
let charCount = 0;
|
||||
try {
|
||||
charCount = JSON.stringify(conversation).length;
|
||||
} catch {
|
||||
charCount = conversation.reduce(
|
||||
(total, message) => total + String(message?.content ?? '').length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
const normalizedMessageThreshold = Math.max(1, Number(messageThreshold) || 180);
|
||||
const normalizedCharThreshold = Math.max(1, Number(charThreshold) || 120_000);
|
||||
if (
|
||||
messageCount < normalizedMessageThreshold
|
||||
&& charCount < normalizedCharThreshold
|
||||
) {
|
||||
return {
|
||||
compacted: false,
|
||||
reason: 'below_threshold',
|
||||
conversation,
|
||||
messageCount,
|
||||
charCount,
|
||||
};
|
||||
}
|
||||
|
||||
const update = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ conversation: [] }),
|
||||
},
|
||||
);
|
||||
if (!update.ok) {
|
||||
const freshSessionRequired = update.status === 404 || update.status === 405;
|
||||
const error = new Error(
|
||||
freshSessionRequired
|
||||
? '当前 Agent 运行时不支持原地压缩会话,需要迁移到新会话'
|
||||
: `会话上下文压缩失败 (${update.status})`,
|
||||
);
|
||||
error.code = freshSessionRequired
|
||||
? 'SESSION_CONTEXT_FRESH_SESSION_REQUIRED'
|
||||
: 'SESSION_CONTEXT_COMPACTION_FAILED';
|
||||
error.retryable = false;
|
||||
error.status = update.status;
|
||||
error.conversation = conversation;
|
||||
error.messageCount = messageCount;
|
||||
error.charCount = charCount;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
compacted: true,
|
||||
conversation,
|
||||
messageCount,
|
||||
charCount,
|
||||
status: update.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function repairSessionToolHistory(sessionId) {
|
||||
if (!sessionId) return { changed: false, updated: false };
|
||||
const target = await resolveTarget(sessionId);
|
||||
@@ -1980,6 +2071,12 @@ export function createTkmindProxy({
|
||||
const finishPromise = consumeSessionEventsUntilFinish(eventsResponse.body, {
|
||||
requestId,
|
||||
timeoutMs,
|
||||
// A brand-new Goose session can replay an initial unscoped Finish before
|
||||
// the POST /reply request starts. Require activity for this request before
|
||||
// accepting an unscoped Finish so a zero-token stale frame cannot mark the
|
||||
// agent run complete.
|
||||
requireRequestActivityBeforeUnscopedFinish: true,
|
||||
emptyFinishGraceMs: 1_500,
|
||||
});
|
||||
// Prevent unhandled rejection from killing the Portal process when reply
|
||||
// setup fails before we await Finish (e.g. provider Error frames arrive later).
|
||||
@@ -2003,7 +2100,38 @@ export function createTkmindProxy({
|
||||
throw new Error(text || `发送失败 (${replyResponse.status})`);
|
||||
}
|
||||
replyResponse.body?.cancel?.().catch?.(() => {});
|
||||
const finishResult = await trackedFinish;
|
||||
let finishResult = await trackedFinish;
|
||||
if (!finishResult.ok && finishResult.error?.code === 'SESSION_EMPTY_FINISH') {
|
||||
eventsResponse.body?.cancel?.().catch?.(() => {});
|
||||
const retryEventsResponse = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/events`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
if (!retryEventsResponse.ok || !retryEventsResponse.body) {
|
||||
const text = await retryEventsResponse.text().catch(() => '');
|
||||
throw new Error(
|
||||
text || `无法重新建立 session 事件流 (${retryEventsResponse.status})`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const retriedFinish = await consumeSessionEventsUntilFinish(
|
||||
retryEventsResponse.body,
|
||||
{
|
||||
requestId,
|
||||
timeoutMs: Math.max(1, timeoutMs - 1_500),
|
||||
requireRequestActivityBeforeUnscopedFinish: true,
|
||||
},
|
||||
);
|
||||
finishResult = { ok: true, value: retriedFinish };
|
||||
} finally {
|
||||
retryEventsResponse.body?.cancel?.().catch?.(() => {});
|
||||
}
|
||||
}
|
||||
if (!finishResult.ok) throw finishResult.error;
|
||||
const finish = finishResult.value;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
|
||||
@@ -2494,6 +2622,19 @@ export function createTkmindProxy({
|
||||
|
||||
const billingTransform = createSseBillingTransform({
|
||||
onFinish: async (event) => {
|
||||
// Page delivery finalization must not depend on billing. The Agent Run
|
||||
// may already have billed this request, and a transient/idempotency
|
||||
// failure here must not leave its HTML contract stuck in `preparing`.
|
||||
if (typeof onAfterFinish === 'function') {
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
onAfterFinish(
|
||||
sessionId,
|
||||
req.currentUser.id,
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, event.token_state);
|
||||
const result = await userAuth.billSessionUsage(
|
||||
@@ -2513,10 +2654,6 @@ export function createTkmindProxy({
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fire-and-forget snapshot refresh after conversation finishes.
|
||||
if (typeof onAfterFinish === 'function') {
|
||||
void Promise.resolve().then(() => onAfterFinish(sessionId, req.currentUser.id)).catch(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2749,6 +2886,7 @@ export function createTkmindProxy({
|
||||
resolveTarget,
|
||||
startSessionForUser,
|
||||
fetchSessionConversationForUser,
|
||||
compactSessionConversationForUser,
|
||||
getRuntimeStatus,
|
||||
submitSessionReplyForUser,
|
||||
submitSessionReplyAndAwaitFinishForUser,
|
||||
|
||||
@@ -655,6 +655,90 @@ test('proxySessionEvents does not send JSON after SSE headers were sent', async
|
||||
}
|
||||
});
|
||||
|
||||
test('proxySessionEvents finalizes page delivery even when Finish billing fails', async () => {
|
||||
let upstream;
|
||||
|
||||
try {
|
||||
upstream = createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/sessions/session-1/events') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
res.end(
|
||||
'data: '
|
||||
+ JSON.stringify({
|
||||
type: 'Finish',
|
||||
request_id: 'request-1',
|
||||
token_state: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
})
|
||||
+ '\n\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` }));
|
||||
});
|
||||
const upstreamPort = await listen(upstream);
|
||||
const userAuth = createMemoryTestUserAuth(process.cwd());
|
||||
userAuth.billSessionUsage = async () => {
|
||||
throw new Error('billing unavailable');
|
||||
};
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget: `http://127.0.0.1:${upstreamPort}`,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth,
|
||||
});
|
||||
|
||||
const req = new EventEmitter();
|
||||
req.currentUser = { id: 'user-1', username: 'john' };
|
||||
req.get = () => '';
|
||||
req.once = req.once.bind(req);
|
||||
req.off = req.off.bind(req);
|
||||
|
||||
const res = new EventEmitter();
|
||||
res.headersSent = false;
|
||||
res.writableEnded = false;
|
||||
res.status = () => res;
|
||||
res.setHeader = () => {};
|
||||
res.flushHeaders = () => {
|
||||
res.headersSent = true;
|
||||
};
|
||||
res.write = () => true;
|
||||
res.end = () => {
|
||||
res.writableEnded = true;
|
||||
};
|
||||
|
||||
let resolveAfterFinish;
|
||||
const afterFinishCalled = new Promise((resolve) => {
|
||||
resolveAfterFinish = resolve;
|
||||
});
|
||||
await proxy.proxySessionEvents(
|
||||
req,
|
||||
res,
|
||||
'session-1',
|
||||
{
|
||||
async onAfterFinish() {
|
||||
resolveAfterFinish();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
afterFinishCalled,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('page delivery finalization was skipped')),
|
||||
100,
|
||||
),
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
await closeServer(upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test('proxySessionEvents attaches session taxonomy when flag enabled', async () => {
|
||||
const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY;
|
||||
process.env.MEMIND_SSE_EVENT_TAXONOMY = '1';
|
||||
@@ -1304,6 +1388,72 @@ test('submitSessionReplyForUser requests a fresh session when Goose cannot updat
|
||||
}, { conversation });
|
||||
});
|
||||
|
||||
test('compactSessionConversationForUser clears oversized Goose history and returns it for recovery context', async () => {
|
||||
const conversation = [
|
||||
{ role: 'user', content: [{ type: 'text', text: '创建问卷页面' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: '页面已创建' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: '继续修改第 20 轮' }] },
|
||||
];
|
||||
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, updateBodies }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await proxy.compactSessionConversationForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
{ messageThreshold: 3, charThreshold: 1_000_000 },
|
||||
);
|
||||
|
||||
assert.equal(result.compacted, true);
|
||||
assert.equal(result.messageCount, 3);
|
||||
assert.deepEqual(result.conversation, conversation);
|
||||
assert.equal(updateBodies.length, 1);
|
||||
assert.deepEqual(updateBodies[0].conversation, []);
|
||||
}, { conversation, allowConversationUpdate: true });
|
||||
});
|
||||
|
||||
test('compactSessionConversationForUser requests transparent rotation when Goose cannot update history', async () => {
|
||||
const conversation = [
|
||||
{ role: 'user', content: [{ type: 'text', text: '第 26 轮继续修改' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'DURABILITY-26' }] },
|
||||
];
|
||||
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
proxy.compactSessionConversationForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
{ messageThreshold: 2, charThreshold: 1_000_000 },
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'SESSION_CONTEXT_FRESH_SESSION_REQUIRED');
|
||||
assert.deepEqual(error.conversation, conversation);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}, { conversation, allowConversationUpdate: false });
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser fails closed when historical image scrub is unsupported', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
|
||||
Reference in New Issue
Block a user