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
+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,