3155b5805f
MEMIND_SESSION_REPLY_IDLE_TIMEOUT_MS (off by default, min 30s) fails a reply that stops streaming while no tool is running, and is recoverable like other finish errors. The goose Finish reason is surfaced in session_finished. Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { ReadableStream } from 'node:stream/web';
|
|
import {
|
|
classifySessionProviderErrorMessage,
|
|
consumeSessionEventsUntilFinish,
|
|
eventMatchesRequest,
|
|
parseSessionStreamEvent,
|
|
resolveSessionReplyIdleTimeoutMs,
|
|
} from './session-reply-wait.mjs';
|
|
|
|
function timedStream(steps) {
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
let at = 0;
|
|
for (const step of steps) {
|
|
at += step.afterMs ?? 0;
|
|
setTimeout(() => {
|
|
if (step.close) controller.close();
|
|
else controller.enqueue(new TextEncoder().encode(step.frame));
|
|
}, at);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
const toolRequestFrame = (id) =>
|
|
`data: ${JSON.stringify({
|
|
type: 'Message',
|
|
request_id: 'req-1',
|
|
message: { role: 'assistant', content: [{ type: 'toolRequest', id, toolCall: { status: 'success', value: { name: 'sandbox-fs__write_file' } } }] },
|
|
})}\n\n`;
|
|
const toolResponseFrame = (id) =>
|
|
`data: ${JSON.stringify({
|
|
type: 'Message',
|
|
request_id: 'req-1',
|
|
message: { role: 'user', content: [{ type: 'toolResponse', id, toolResult: { status: 'success', value: { content: [] } } }] },
|
|
})}\n\n`;
|
|
|
|
test('resolveSessionReplyIdleTimeoutMs is off by default and floors small values', () => {
|
|
assert.equal(resolveSessionReplyIdleTimeoutMs({}), 0);
|
|
assert.equal(resolveSessionReplyIdleTimeoutMs({ MEMIND_SESSION_REPLY_IDLE_TIMEOUT_MS: 'abc' }), 0);
|
|
assert.equal(resolveSessionReplyIdleTimeoutMs({ MEMIND_SESSION_REPLY_IDLE_TIMEOUT_MS: '0' }), 0);
|
|
assert.equal(resolveSessionReplyIdleTimeoutMs({ MEMIND_SESSION_REPLY_IDLE_TIMEOUT_MS: '5' }), 30_000);
|
|
assert.equal(resolveSessionReplyIdleTimeoutMs({ MEMIND_SESSION_REPLY_IDLE_TIMEOUT_MS: '300000' }), 300_000);
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish fails fast when the model goes silent', async () => {
|
|
const stream = timedStream([
|
|
{ frame: 'data: {"type":"Message","request_id":"req-1","message":{"role":"assistant"}}\n\n' },
|
|
]);
|
|
const started = Date.now();
|
|
await assert.rejects(
|
|
consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000, idleTimeoutMs: 60 }),
|
|
(error) => error.code === 'SESSION_REPLY_IDLE_TIMEOUT',
|
|
);
|
|
assert.ok(Date.now() - started < 2000);
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish does not idle-timeout while a tool call is running', async () => {
|
|
const stream = timedStream([
|
|
{ frame: toolRequestFrame('t1') },
|
|
{ afterMs: 200, frame: toolResponseFrame('t1') },
|
|
{ afterMs: 10, frame: 'data: {"type":"Finish","request_id":"req-1","reason":"stop","token_state":{"totalTokens":3}}\n\n' },
|
|
]);
|
|
const result = await consumeSessionEventsUntilFinish(stream, {
|
|
requestId: 'req-1',
|
|
timeoutMs: 5000,
|
|
idleTimeoutMs: 60,
|
|
});
|
|
assert.equal(result.finishReason, 'stop');
|
|
assert.deepEqual(result.toolEvidence.calls, ['sandbox-fs__write_file']);
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish exposes the Goose finish reason', async () => {
|
|
const stream = timedStream([
|
|
{ frame: 'data: {"type":"Message","request_id":"req-1","message":{"role":"assistant"}}\n\n' },
|
|
{ frame: 'data: {"type":"Finish","request_id":"req-1","reason":"max_turns","token_state":{"totalTokens":9}}\n\n' },
|
|
]);
|
|
const result = await consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 });
|
|
assert.equal(result.finishReason, 'max_turns');
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish defaults finishReason to stop for older goosed', async () => {
|
|
const stream = timedStream([
|
|
{ frame: 'data: {"type":"Message","request_id":"req-1","message":{"role":"assistant"}}\n\n' },
|
|
{ frame: 'data: {"type":"Finish","request_id":"req-1","token_state":{"totalTokens":9}}\n\n' },
|
|
]);
|
|
const result = await consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 });
|
|
assert.equal(result.finishReason, 'stop');
|
|
});
|
|
|
|
test('parseSessionStreamEvent parses SSE data payload', () => {
|
|
const event = parseSessionStreamEvent('id: 1\ndata: {"type":"Finish","token_state":null}\n');
|
|
assert.equal(event?.type, 'Finish');
|
|
});
|
|
|
|
test('eventMatchesRequest scopes by request id when present', () => {
|
|
assert.equal(eventMatchesRequest({ request_id: 'req-1' }, 'req-1'), true);
|
|
assert.equal(eventMatchesRequest({ request_id: 'req-2' }, 'req-1'), false);
|
|
assert.equal(eventMatchesRequest({ type: 'Finish' }, 'req-1'), true);
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish resolves on Finish', async () => {
|
|
const frames = [
|
|
'data: {"type":"Message","request_id":"req-1","message":{"role":"assistant"}}\n\n',
|
|
'data: {"type":"Finish","request_id":"req-1","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-1', timeoutMs: 5000 });
|
|
assert.equal(result.finishEvent.type, 'Finish');
|
|
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,
|
|
jobId: 'job-1',
|
|
source: { mimeType: 'image/webp' },
|
|
asset: {
|
|
id: 'asset-1',
|
|
publicUrl: '/MindSpace/user/public/images/hero.webp',
|
|
workspaceRelativePath: 'public/images/hero.webp',
|
|
},
|
|
});
|
|
const frames = [
|
|
`data: ${JSON.stringify({
|
|
type: 'Message', request_id: 'req-1', message: {
|
|
content: [{
|
|
type: 'toolRequest', id: 'call-1',
|
|
toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } },
|
|
}],
|
|
},
|
|
})}\n\n`,
|
|
`data: ${JSON.stringify({
|
|
type: 'Message', request_id: 'req-1', message: {
|
|
content: [{
|
|
type: 'toolResponse', id: 'call-1',
|
|
toolResult: { status: 'success', value: { content: [{ type: 'text', text: toolResult }], isError: false } },
|
|
}],
|
|
},
|
|
})}\n\n`,
|
|
'data: {"type":"Finish","request_id":"req-1"}\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-1', timeoutMs: 5000 });
|
|
assert.equal(result.toolEvidence.generateImage.called, true);
|
|
assert.equal(result.toolEvidence.generateImage.succeeded, true);
|
|
assert.equal(result.toolEvidence.generateImage.jobId, 'job-1');
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish surfaces reasoning_content protocol errors before Finish', async () => {
|
|
const frames = [
|
|
`data: ${JSON.stringify({
|
|
type: 'Message',
|
|
request_id: 'req-1',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'text',
|
|
text: 'Ran into this error: Bad request (400): The reasoning_content in the thinking mode must be passed back to the API.\n\nPlease retry if you think this is a transient or recoverable error.',
|
|
}],
|
|
},
|
|
})}\n\n`,
|
|
'data: {"type":"Finish","request_id":"req-1"}\n\n',
|
|
];
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 }),
|
|
(error) => {
|
|
assert.equal(error.code, 'SESSION_REASONING_CONTENT_POISONED');
|
|
assert.match(error.message, /reasoning_content/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish surfaces provider tool history errors before Finish', async () => {
|
|
const frames = [
|
|
`data: ${JSON.stringify({
|
|
type: 'Message',
|
|
request_id: 'req-1',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'text',
|
|
text: "Ran into this error: Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).\n\nPlease retry if you think this is a transient or recoverable error.",
|
|
}],
|
|
},
|
|
})}\n\n`,
|
|
'data: {"type":"Finish","request_id":"req-1"}\n\n',
|
|
];
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 }),
|
|
(error) => {
|
|
assert.equal(error.code, 'SESSION_TOOL_HISTORY_POISONED');
|
|
assert.match(error.message, /tool_calls/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('consumeSessionEventsUntilFinish classifies unsupported visual history as degradable', async () => {
|
|
const message =
|
|
'Request failed: Bad request (400): Failed to deserialize the JSON body into the target type: messages[8]: unknown variant `image_url`, expected `text`';
|
|
assert.equal(
|
|
classifySessionProviderErrorMessage(message),
|
|
'SESSION_VISUAL_CONTEXT_UNSUPPORTED',
|
|
);
|
|
|
|
const frames = [
|
|
`data: ${JSON.stringify({
|
|
type: 'Message',
|
|
request_id: 'req-visual',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'text',
|
|
text: `Ran into this error: ${message}\n\nPlease retry if you think this is a transient or recoverable error.`,
|
|
}],
|
|
},
|
|
})}\n\n`,
|
|
'data: {"type":"Finish","request_id":"req-visual"}\n\n',
|
|
];
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
consumeSessionEventsUntilFinish(stream, {
|
|
requestId: 'req-visual',
|
|
timeoutMs: 5000,
|
|
}),
|
|
(error) => {
|
|
assert.equal(error.code, 'SESSION_VISUAL_CONTEXT_UNSUPPORTED');
|
|
assert.equal(error.retryable, false);
|
|
return true;
|
|
},
|
|
);
|
|
});
|