feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复

落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 14:19:48 +08:00
parent e2ad3bf62b
commit 08feae8bef
41 changed files with 2728 additions and 130 deletions
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildH5HtmlRepairPrompt,
collectMissingPublicAssetReferences,
collectMissingPublicHtmlPaths,
evaluateH5HtmlFinishGuard,
isH5HtmlFinishGuardEnabled,
maybeRepairH5HtmlAfterFinish,
resetH5HtmlFinishGuardAttempts,
} from './mindspace-h5-html-finish-guard.mjs';
const USER = { id: 'user-1', username: 'john' };
test('collectMissingPublicAssetReferences finds missing svg referenced by html', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
try {
fs.mkdirSync(path.join(publishDir, 'public', 'assets'), { recursive: true });
fs.writeFileSync(
path.join(publishDir, 'public', 'kids-posture-business.html'),
'<img src="assets/kids-posture-hero.svg" alt="hero">',
'utf8',
);
const missing = collectMissingPublicAssetReferences(publishDir);
assert.deepEqual(missing, [{
relativePath: 'public/assets/kids-posture-hero.svg',
htmlRelativePath: 'public/kids-posture-business.html',
}]);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('collectMissingPublicHtmlPaths detects assistant link without disk file', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
try {
const messages = [{
role: 'assistant',
content: [{
type: 'text',
text: '[页面](https://m.tkmind.cn/MindSpace/user-1/public/missing-page.html) 已生成',
}],
}];
const missing = collectMissingPublicHtmlPaths(messages, USER, publishDir);
assert.deepEqual(missing, ['public/missing-page.html']);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('evaluateH5HtmlFinishGuard flags missing assets after html exists', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(
path.join(publishDir, 'public', 'page.html'),
'<html><body><img src="assets/icon.svg"></body></html>',
'utf8',
);
const evaluation = evaluateH5HtmlFinishGuard({
messages: [],
currentUser: USER,
publishDir,
});
assert.equal(evaluation.needsRepair, true);
assert.equal(evaluation.missingAssets.length, 1);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('buildH5HtmlRepairPrompt lists html and asset targets', () => {
const prompt = buildH5HtmlRepairPrompt({
missingHtml: ['public/page.html'],
missingAssets: [{ relativePath: 'public/assets/icon.svg', htmlRelativePath: 'public/page.html' }],
});
assert.match(prompt, /public\/page\.html/);
assert.match(prompt, /public\/assets\/icon\.svg/);
assert.match(prompt, /write_file/);
});
test('maybeRepairH5HtmlAfterFinish triggers proxy reply when enabled', async () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD;
process.env.MEMIND_H5_HTML_FINISH_GUARD = '1';
resetH5HtmlFinishGuardAttempts('session-1');
const calls = [];
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(
path.join(publishDir, 'public', 'page.html'),
'<img src="assets/icon.svg">',
'utf8',
);
const result = await maybeRepairH5HtmlAfterFinish({
sessionId: 'session-1',
userId: 'user-1',
currentUser: USER,
messages: [],
publishDir,
tkmindProxy: {
async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
calls.push({ userId, sessionId, requestId, userMessage });
},
},
});
assert.equal(result.repaired, true);
assert.equal(calls.length, 1);
assert.match(calls[0].userMessage.content[0].text, /icon\.svg/);
} finally {
if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous;
resetH5HtmlFinishGuardAttempts('session-1');
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('maybeRepairH5HtmlAfterFinish stays off when flag disabled', async () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD;
delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(
path.join(publishDir, 'public', 'page.html'),
'<img src="assets/icon.svg">',
'utf8',
);
const result = await maybeRepairH5HtmlAfterFinish({
sessionId: 'session-2',
userId: 'user-1',
currentUser: USER,
messages: [],
publishDir,
tkmindProxy: {
async submitSessionReplyForUser() {
throw new Error('should not call proxy when disabled');
},
},
});
assert.equal(result.skipped, 'disabled');
assert.equal(isH5HtmlFinishGuardEnabled(), false);
} finally {
if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous;
fs.rmSync(publishDir, { recursive: true, force: true });
}
});