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

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