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:
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user