This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -12,6 +12,8 @@ import type { AppConfig } from './config.ts';
import { prepareEventStreamResponse } from './http.ts';
import { requestIdMiddleware } from './middleware/requestId.ts';
import { createAppContext } from './server.ts';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
import { httpRequest, type TestRequestInit } from './testHttp.ts';
type TestConfigOverrides = Partial<
@@ -29,6 +31,16 @@ type TestConfigOverrides = Partial<
type TestAppContext = Awaited<ReturnType<typeof createAppContext>>;
function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) {
context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator(
context.customWorldAgentSessions,
null,
{
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
},
);
}
function createTestConfig(
testName: string,
overrides: TestConfigOverrides = {},
@@ -1698,6 +1710,37 @@ test('authenticated missing routes return unified not found errors', async () =>
});
});
test('public runtime assets are served from the app root', async () => {
const publicDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-public-static-'),
);
const generatedDir = path.join(
publicDir,
'generated-character-drafts',
'test-npc',
'visual',
'visual-draft-1',
);
fs.mkdirSync(generatedDir, { recursive: true });
const imagePath = path.join(generatedDir, 'candidate-01.png');
fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
await withTestServer(
'public-runtime-assets',
async ({ baseUrl }) => {
const response = await httpRequest(
`${baseUrl}/generated-character-drafts/test-npc/visual/visual-draft-1/candidate-01.png`,
);
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type'), 'image/png');
},
{
publicDir,
},
);
});
test('stream responses also carry api version and route metadata headers', async () => {
const app = express();
app.use(requestIdMiddleware);
@@ -2146,6 +2189,129 @@ test('custom worlds stay private until published and then appear in the public g
});
});
test('deleting a custom world uses soft delete and hides the work from library and gallery', async () => {
await withTestServer(
'custom-world-soft-delete',
async ({ baseUrl, context }) => {
const owner = await authEntry(baseUrl, 'soft_delete_owner', 'secret123');
const viewer = await authEntry(
baseUrl,
'soft_delete_viewer',
'secret123',
);
const upsertResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
withBearer(owner.token, {
method: 'PUT',
body: JSON.stringify({
profile: {
id: 'world-soft-delete',
name: '潮雾裂港',
subtitle: '被旧航灯切开的海港',
summary: '用于验证作品删除软删除逻辑的测试世界。',
playableNpcs: [],
storyNpcs: [],
landmarks: [],
},
}),
}),
);
assert.equal(upsertResponse.status, 200);
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete/publish`,
withBearer(owner.token, {
method: 'POST',
}),
);
assert.equal(publishResponse.status, 200);
const deleteResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
withBearer(owner.token, {
method: 'DELETE',
}),
);
const deletePayload = (await deleteResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(deleteResponse.status, 200);
assert.deepEqual(deletePayload.entries, []);
const ownerLibraryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library`,
{
headers: {
Authorization: `Bearer ${owner.token}`,
},
},
);
const ownerLibraryPayload = (await ownerLibraryResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(ownerLibraryResponse.status, 200);
assert.deepEqual(ownerLibraryPayload.entries, []);
const galleryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryPayload = (await galleryResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(galleryResponse.status, 200);
assert.deepEqual(galleryPayload.entries, []);
const galleryDetailResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(owner.user.id)}/${encodeURIComponent('world-soft-delete')}`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
assert.equal(galleryDetailResponse.status, 404);
const persistedRows = await context.db.query<{
profileId: string;
visibility: string;
publishedAt: string | null;
deletedAt: string | null;
payload: {
name?: string;
};
}>(
`SELECT profile_id AS "profileId",
visibility,
published_at AS "publishedAt",
deleted_at AS "deletedAt",
payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2`,
[owner.user.id, 'world-soft-delete'],
);
assert.equal(persistedRows.rows.length, 1);
assert.equal(persistedRows.rows[0]?.profileId, 'world-soft-delete');
assert.equal(persistedRows.rows[0]?.visibility, 'draft');
assert.equal(persistedRows.rows[0]?.publishedAt, null);
assert.ok(persistedRows.rows[0]?.deletedAt);
assert.equal(persistedRows.rows[0]?.payload?.name, '潮雾裂港');
},
);
});
test('custom world works endpoint returns draft sessions and published worlds together', async () => {
await withTestServer('custom-world-works', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'cw_works', 'secret123');
@@ -2231,107 +2397,111 @@ test('custom world works endpoint returns draft sessions and published worlds to
});
test('custom world agent session accepts messages and exposes completed operations', async () => {
await withTestServer('custom-world-agent-messages', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123');
await withTestServer(
'custom-world-agent-messages',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123');
const createResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
seedText: '一个围绕灯塔与沉船秘术的边境世界。',
const createResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
seedText: '一个围绕灯塔与沉船秘术的边境世界。',
}),
}),
}),
);
const created = (await createResponse.json()) as {
session: {
sessionId: string;
messages: Array<{ role: string }>;
);
const created = (await createResponse.json()) as {
session: {
sessionId: string;
messages: Array<{ role: string }>;
};
};
};
assert.equal(createResponse.status, 200);
assert.equal(created.session.messages[0]?.role, 'assistant');
assert.equal(createResponse.status, 200);
assert.equal(created.session.messages[0]?.role, 'assistant');
const messageResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
clientMessageId: 'client-1',
text: '玩家是一个被迫回到故乡灯塔的失职守望者。',
focusCardId: null,
selectedCardIds: [],
const messageResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
clientMessageId: 'client-1',
text: '玩家是一个被迫回到故乡灯塔的失职守望者。',
focusCardId: null,
selectedCardIds: [],
}),
}),
}),
);
const messagePayload = (await messageResponse.json()) as {
operation: {
operationId: string;
status: string;
progress: number;
);
const messagePayload = (await messageResponse.json()) as {
operation: {
operationId: string;
status: string;
progress: number;
};
};
};
assert.equal(messageResponse.status, 200);
assert.equal(messagePayload.operation.status, 'queued');
assert.equal(messagePayload.operation.progress, 10);
assert.equal(messageResponse.status, 200);
assert.equal(messagePayload.operation.status, 'queued');
assert.equal(messagePayload.operation.progress, 10);
let operationText = '';
let operationText = '';
for (let attempt = 0; attempt < 20; attempt += 1) {
const operationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
for (let attempt = 0; attempt < 20; attempt += 1) {
const operationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
assert.equal(operationResponse.status, 200);
operationText = await operationResponse.text();
if (/"status":"completed"/u.test(operationText)) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
assert.match(operationText, /"status":"completed"/u);
assert.match(operationText, /"progress":100/u);
const sessionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
assert.equal(operationResponse.status, 200);
operationText = await operationResponse.text();
const sessionPayload = (await sessionResponse.json()) as {
stage: string;
creatorIntent: {
playerPremise?: string | null;
} | null;
messages: Array<{ role: string; text: string }>;
pendingClarifications: Array<{ question: string }>;
};
if (/"status":"completed"/u.test(operationText)) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
assert.match(operationText, /"status":"completed"/u);
assert.match(operationText, /"progress":100/u);
const sessionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const sessionPayload = (await sessionResponse.json()) as {
stage: string;
creatorIntent: {
playerPremise?: string | null;
} | null;
messages: Array<{ role: string; text: string }>;
pendingClarifications: Array<{ question: string }>;
};
assert.equal(sessionResponse.status, 200);
assert.equal(sessionPayload.stage, 'clarifying');
assert.ok(
sessionPayload.messages.some((message) => message.role === 'user'),
);
assert.ok(
sessionPayload.messages.some((message) => message.role === 'assistant'),
);
assert.match(
sessionPayload.creatorIntent?.playerPremise ?? '',
/|/u,
);
assert.ok(sessionPayload.pendingClarifications.length > 0);
});
assert.equal(sessionResponse.status, 200);
assert.equal(sessionPayload.stage, 'clarifying');
assert.ok(
sessionPayload.messages.some((message) => message.role === 'user'),
);
assert.ok(
sessionPayload.messages.some((message) => message.role === 'assistant'),
);
assert.match(
sessionPayload.creatorIntent?.playerPremise ?? '',
/|/u,
);
assert.ok(sessionPayload.pendingClarifications.length > 0);
},
);
});
test('custom world agent missing session returns 404', async () => {
@@ -2433,7 +2603,8 @@ test('custom world agent operation can fail and expose failed status', async ()
test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => {
await withTestServer(
'custom-world-agent-phase3-http',
async ({ baseUrl }) => {
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123');
const readySession = await createReadyCustomWorldAgentSession({
baseUrl,
@@ -2568,7 +2739,10 @@ test('custom world agent draft_foundation action rejects not-ready sessions over
assert.equal(actionResponse.status, 400);
assert.equal(actionPayload.error.code, 'BAD_REQUEST');
assert.match(actionPayload.error.message, /foundation_review|ready/u);
assert.match(
actionPayload.error.message,
/progressPercent >= 100|draft_foundation/u,
);
},
);
});
@@ -2577,6 +2751,7 @@ test('custom world agent update_draft_card action updates draft profile and card
await withTestServer(
'custom-world-agent-phase4-update-http',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_agent_phase4_update',
@@ -2718,6 +2893,7 @@ test('custom world agent generate_characters action appends character cards over
await withTestServer(
'custom-world-agent-phase4-generate-characters-http',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
@@ -2817,6 +2993,7 @@ test('custom world agent generate_landmarks action appends landmark cards over h
await withTestServer(
'custom-world-agent-phase4-generate-landmarks-http',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
@@ -3305,14 +3482,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
'2026-04-16T12:00:00.000Z',
);
const viewerHistoryResponse = await httpRequest(
browseHistoryUrl,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
const viewerHistoryResponse = await httpRequest(browseHistoryUrl, {
headers: {
Authorization: `Bearer ${viewer.token}`,
},
);
});
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
entries: Array<{
profileId: string;
@@ -3346,14 +3520,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
['world-1', 'world-2'],
);
const authorHistoryResponse = await httpRequest(
browseHistoryUrl,
{
headers: {
Authorization: `Bearer ${author.token}`,
},
const authorHistoryResponse = await httpRequest(browseHistoryUrl, {
headers: {
Authorization: `Bearer ${author.token}`,
},
);
});
const authorHistoryPayload = (await authorHistoryResponse.json()) as {
entries: Array<unknown>;
};
@@ -3374,14 +3545,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
assert.equal(clearResponse.status, 200);
assert.deepEqual(clearPayload.entries, []);
const clearedHistoryResponse = await httpRequest(
browseHistoryUrl,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
const clearedHistoryResponse = await httpRequest(browseHistoryUrl, {
headers: {
Authorization: `Bearer ${viewer.token}`,
},
);
});
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
entries: Array<unknown>;
};