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
+122
View File
@@ -464,6 +464,40 @@ test('POST /agent/runs rejects a session the user does not own', async () => {
assert.deepEqual(res.body, { message: '无权访问该会话' });
});
test('POST /agent/runs validates ownership through sessionAccess when provided', async () => {
const validated = [];
const handler = createPostAgentRunsHandler({
userAuth: {},
sessionAccess: {
async validateOwnership(userId, sessionId) {
validated.push({ userId, sessionId });
return false;
},
},
agentRunGateway: {
async createRun() {
throw new Error('should not be called');
},
},
});
const res = createResponseRecorder();
await handler(
{
currentUser: { id: 'user-1' },
body: {
session_id: 'session-broker',
request_id: 'req-broker',
user_message: { role: 'user', content: [] },
},
},
res,
);
assert.deepEqual(validated, [{ userId: 'user-1', sessionId: 'session-broker' }]);
assert.equal(res.statusCode, 403);
});
test('GET /agent/runs/:runId dispatches unfinished runs', async () => {
const dispatched = [];
const handler = createGetAgentRunHandler({
@@ -550,6 +584,94 @@ test('GET /agent/runs/:runId/events streams the current run and closes on termin
assert.equal(res.ended, true);
});
test('GET /agent/runs/:runId/events attaches run taxonomy when flag enabled', async () => {
const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY;
process.env.MEMIND_SSE_EVENT_TAXONOMY = '1';
try {
const handler = createAgentRunEventsHandler({
agentRunGateway: {
async getRunForUser() {
return { id: 'run-tax', status: 'succeeded', sessionId: 'session-1' };
},
dispatchRun() {},
},
pollIntervalMs: 5,
keepaliveIntervalMs: 50,
});
const res = createSseResponseRecorder();
await handler(
{
currentUser: { id: 'user-1' },
params: { runId: 'run-tax' },
on() {},
},
res,
);
assert.match(res.chunks.join(''), /"taxonomy":"terminal"/);
} finally {
if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY;
else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous;
}
});
test('GET /agent/runs/:runId/events replay mode emits SSE ids and replays after Last-Event-ID', async () => {
const events = [
{
id: 'evt-1',
eventType: 'run_snapshot',
data: { run: { id: 'run-replay', status: 'running', sessionId: null } },
createdAt: 1,
},
{
id: 'evt-2',
eventType: 'run_snapshot',
data: { run: { id: 'run-replay', status: 'succeeded', sessionId: 'session-9' } },
createdAt: 2,
},
];
const handler = createAgentRunEventsHandler({
replayEnabled: true,
agentRunGateway: {
async getRunForUser() {
return { id: 'run-replay', status: 'succeeded', sessionId: 'session-9' };
},
async listRunEventsForUser(_userId, _runId, { afterEventId } = {}) {
if (afterEventId === 'evt-1') {
return {
run: { id: 'run-replay', status: 'succeeded', sessionId: 'session-9' },
events: [events[1]],
cursorMiss: false,
};
}
return {
run: { id: 'run-replay', status: 'running', sessionId: null },
events,
cursorMiss: false,
};
},
dispatchRun() {},
},
pollIntervalMs: 1000,
keepaliveIntervalMs: 5000,
});
const res = createSseResponseRecorder();
await handler(
{
currentUser: { id: 'user-1' },
params: { runId: 'run-replay' },
get(name) {
return name.toLowerCase() === 'last-event-id' ? 'evt-1' : undefined;
},
on() {},
},
res,
);
assert.equal(res.ended, true);
const output = res.chunks.join('');
assert.match(output, /id: evt-2/);
assert.match(output, /"sessionId":"session-9"/);
});
test('GET /agent/runs/:runId/events republishes changed run state before terminal close', async () => {
let readCount = 0;
const dispatched = [];