refactor: modularize portal server and harden session recovery
This commit is contained in:
+148
-2
@@ -13,10 +13,15 @@ import {
|
||||
} from './tkmind-proxy.mjs';
|
||||
import { createMemoryV2 } from './memory-v2.mjs';
|
||||
|
||||
async function withFakeGoosedSession(handler, { conversation = [] } = {}) {
|
||||
async function withFakeGoosedSession(
|
||||
handler,
|
||||
{ conversation = [], allowConversationUpdate = false } = {},
|
||||
) {
|
||||
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-'));
|
||||
const harnessEntries = [];
|
||||
const replyBodies = [];
|
||||
const updateBodies = [];
|
||||
let activeConversation = conversation;
|
||||
let server;
|
||||
|
||||
try {
|
||||
@@ -42,11 +47,18 @@ async function withFakeGoosedSession(handler, { conversation = [] } = {}) {
|
||||
id: 'session-1',
|
||||
working_dir: workingDir,
|
||||
goose_mode: 'chat',
|
||||
conversation,
|
||||
conversation: activeConversation,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'PUT' && req.url === '/sessions/session-1') {
|
||||
if (allowConversationUpdate) {
|
||||
updateBodies.push(body);
|
||||
activeConversation = body.conversation;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: 'method not allowed' }));
|
||||
return;
|
||||
@@ -86,6 +98,7 @@ async function withFakeGoosedSession(handler, { conversation = [] } = {}) {
|
||||
workingDir,
|
||||
harnessEntries,
|
||||
replyBodies,
|
||||
updateBodies,
|
||||
});
|
||||
} finally {
|
||||
if (server?.listening) {
|
||||
@@ -881,6 +894,7 @@ test('getRuntimeStatus preserves Memory V2 status contract for release gates', a
|
||||
'eventLogEnabled',
|
||||
'failOpen',
|
||||
'profileEnabled',
|
||||
'runtimeControl',
|
||||
'selectedBackend',
|
||||
'vectorEnabled',
|
||||
]);
|
||||
@@ -940,6 +954,138 @@ test('submitSessionReplyForUser adds goose metadata visibility flags before repl
|
||||
});
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser repairs poisoned tool history before sending the next turn', async () => {
|
||||
const conversation = [
|
||||
{ role: 'user', content: [{ type: 'text', text: '生成页面' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolRequest',
|
||||
id: 'call-a',
|
||||
toolCall: { status: 'success', value: { name: 'write_file', arguments: {} } },
|
||||
}],
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: '继续' }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'toolResponse',
|
||||
id: 'call-a',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: 'ok' }], isError: false },
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies, updateBodies }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
async canUseChat() {
|
||||
return { ok: true };
|
||||
},
|
||||
async getUserById() {
|
||||
return { id: 'user-1' };
|
||||
},
|
||||
async resolveUserPolicies() {
|
||||
return { unrestricted: true, policies: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await proxy.submitSessionReplyForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
'request-after-tool-repair',
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '继续生成' }],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(updateBodies.length, 1);
|
||||
assert.deepEqual(
|
||||
updateBodies[0].conversation.map(
|
||||
(message) => message.content[0]?.id ?? message.content[0]?.text,
|
||||
),
|
||||
['生成页面', 'call-a', 'call-a', '继续'],
|
||||
);
|
||||
assert.equal(replyBodies.length, 1);
|
||||
}, { conversation, allowConversationUpdate: true });
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser requests a fresh session when Goose cannot update history', async () => {
|
||||
const conversation = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolRequest',
|
||||
id: 'call-a',
|
||||
toolCall: { status: 'success', value: { name: 'write_file', arguments: {} } },
|
||||
}],
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: '继续' }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'toolResponse',
|
||||
id: 'call-a',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: 'ok' }], isError: false },
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
async canUseChat() {
|
||||
return { ok: true };
|
||||
},
|
||||
async getUserById() {
|
||||
return { id: 'user-1' };
|
||||
},
|
||||
async resolveUserPolicies() {
|
||||
return { unrestricted: true, policies: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
proxy.submitSessionReplyForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
'request-needs-fresh-session',
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '继续生成' }],
|
||||
},
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED');
|
||||
assert.equal(error.repairedConversation.length, 3);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(replyBodies.length, 0);
|
||||
}, { conversation });
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser fails closed when historical image scrub is unsupported', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
|
||||
Reference in New Issue
Block a user