1
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user