This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -208,7 +208,10 @@ async function authEntry(baseUrl: string, username: string, password: string) {
username: string;
};
};
const refreshCookie = response.headers.get('set-cookie');
const refreshCookie = buildCookieHeader(
response.headers.get('set-cookie'),
'genarrative_refresh_session',
);
assert.equal(response.status, 200);
assert.ok(payload.token);
@@ -263,7 +266,10 @@ async function phoneLogin(baseUrl: string, phone: string, code = '123456') {
wechatBound: boolean;
};
};
const refreshCookie = response.headers.get('set-cookie');
const refreshCookie = buildCookieHeader(
response.headers.get('set-cookie'),
'genarrative_refresh_session',
);
assert.equal(response.status, 200);
assert.ok(payload.token);
@@ -449,6 +455,191 @@ async function createObjectRefiningCustomWorldAgentSession(params: {
return session;
}
async function markAgentSessionPublishReady(params: {
context: TestAppContext;
userId: string;
sessionId: string;
}) {
const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot(
params.userId,
params.sessionId,
);
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | null;
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
? (draftProfile?.playableNpcs as Array<Record<string, unknown>>)
: [];
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
? (draftProfile?.storyNpcs as Array<Record<string, unknown>>)
: [];
const landmarks = Array.isArray(draftProfile?.landmarks)
? (draftProfile?.landmarks as Array<Record<string, unknown>>)
: [];
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
? (draftProfile?.sceneChapters as Array<Record<string, unknown>>)
: [];
const camp =
draftProfile?.camp && typeof draftProfile.camp === 'object'
? (draftProfile.camp as Record<string, unknown>)
: null;
const firstPlayableRoleId =
typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim()
? playableNpcs[0].id.trim()
: null;
const firstStoryRoleId =
typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim()
? storyNpcs[0].id.trim()
: firstPlayableRoleId;
assert.ok(snapshot);
assert.ok(draftProfile);
assert.ok(playableNpcs.length > 0);
assert.ok(storyNpcs.length > 0);
assert.ok(landmarks.length > 0);
assert.ok(sceneChapters.length > 0);
assert.ok(firstStoryRoleId);
await params.context.customWorldAgentSessions.replaceDerivedState(
params.userId,
params.sessionId,
{
stage: 'ready_to_publish',
qualityFindings: [],
draftProfile: {
...draftProfile,
chapters:
Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0
? draftProfile.chapters
: [{ id: 'chapter-main-1', title: '主线第一章' }],
camp: {
...(camp ?? {}),
id:
typeof camp?.id === 'string' && camp.id.trim()
? camp.id.trim()
: 'camp-home',
name:
typeof camp?.name === 'string' && camp.name.trim()
? camp.name.trim()
: '归潮营地',
description:
typeof camp?.description === 'string' && camp.description.trim()
? camp.description.trim()
: '可供玩家整理线索的临时据点。',
imageSrc:
typeof camp?.imageSrc === 'string' && camp.imageSrc.trim()
? camp.imageSrc.trim()
: '/generated/camp/publish-ready.png',
generatedSceneAssetId:
typeof camp?.generatedSceneAssetId === 'string' &&
camp.generatedSceneAssetId.trim()
? camp.generatedSceneAssetId.trim()
: 'scene-camp-publish-ready',
generatedScenePrompt:
typeof camp?.generatedScenePrompt === 'string' &&
camp.generatedScenePrompt.trim()
? camp.generatedScenePrompt.trim()
: '潮雾营地发布正式图',
generatedSceneModel:
typeof camp?.generatedSceneModel === 'string' &&
camp.generatedSceneModel.trim()
? camp.generatedSceneModel.trim()
: 'test-scene-model',
},
playableNpcs: playableNpcs.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/playable/publish-ready-${index + 1}.png`,
generatedVisualAssetId:
typeof entry.generatedVisualAssetId === 'string' &&
entry.generatedVisualAssetId.trim()
? entry.generatedVisualAssetId.trim()
: `visual-playable-publish-${index + 1}`,
generatedAnimationSetId:
typeof entry.generatedAnimationSetId === 'string' &&
entry.generatedAnimationSetId.trim()
? entry.generatedAnimationSetId.trim()
: `anim-playable-publish-${index + 1}`,
})),
storyNpcs: storyNpcs.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/story/publish-ready-${index + 1}.png`,
generatedVisualAssetId:
typeof entry.generatedVisualAssetId === 'string' &&
entry.generatedVisualAssetId.trim()
? entry.generatedVisualAssetId.trim()
: `visual-story-publish-${index + 1}`,
generatedAnimationSetId:
typeof entry.generatedAnimationSetId === 'string' &&
entry.generatedAnimationSetId.trim()
? entry.generatedAnimationSetId.trim()
: `anim-story-publish-${index + 1}`,
})),
landmarks: landmarks.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/landmark/publish-ready-${index + 1}.png`,
generatedSceneAssetId:
typeof entry.generatedSceneAssetId === 'string' &&
entry.generatedSceneAssetId.trim()
? entry.generatedSceneAssetId.trim()
: `scene-landmark-publish-${index + 1}`,
generatedScenePrompt:
typeof entry.generatedScenePrompt === 'string' &&
entry.generatedScenePrompt.trim()
? entry.generatedScenePrompt.trim()
: `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`,
generatedSceneModel:
typeof entry.generatedSceneModel === 'string' &&
entry.generatedSceneModel.trim()
? entry.generatedSceneModel.trim()
: 'test-scene-model',
})),
sceneChapters: sceneChapters.map((chapter, chapterIndex) => {
const acts = Array.isArray(chapter.acts)
? (chapter.acts as Array<Record<string, unknown>>)
: [];
return {
...chapter,
linkedThreadIds:
Array.isArray(chapter.linkedThreadIds) &&
chapter.linkedThreadIds.length > 0
? chapter.linkedThreadIds
: ['thread-publish-ready'],
acts: acts.map((act, actIndex) => ({
...act,
encounterNpcIds:
Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0
? act.encounterNpcIds
: [firstStoryRoleId],
primaryNpcId:
typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim()
? act.primaryNpcId.trim()
: firstStoryRoleId,
backgroundImageSrc:
typeof act.backgroundImageSrc === 'string' &&
act.backgroundImageSrc.trim()
? act.backgroundImageSrc.trim()
: `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`,
backgroundAssetId:
typeof act.backgroundAssetId === 'string' &&
act.backgroundAssetId.trim()
? act.backgroundAssetId.trim()
: `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`,
})),
};
}),
},
},
);
}
function parseRedirectHash(location: string) {
const url = new URL(location, 'http://127.0.0.1');
return new URLSearchParams(
@@ -456,6 +647,18 @@ function parseRedirectHash(location: string) {
);
}
function readCookieValue(cookieHeader: string, cookieName: string) {
const match = cookieHeader.match(
new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'),
);
return match?.[1] ? decodeURIComponent(match[1]) : '';
}
function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) {
const value = readCookieValue(cookieHeader || '', cookieName);
return value ? `${cookieName}=${encodeURIComponent(value)}` : '';
}
async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
const startResponse = await httpRequest(
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`,
@@ -473,18 +676,10 @@ async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
assert.ok(location);
const hash = parseRedirectHash(location);
const setCookieHeader = callbackResponse.headers.get('set-cookie') || '';
const accessCookie = setCookieHeader
.split(',')
.map((entry) => entry.trim())
.find((entry) => entry.startsWith('genarrative_access_session='));
const token =
accessCookie
?.split(';')[0]
?.split('=')
.slice(1)
.join('=')
.trim() || '';
const token = readCookieValue(
setCookieHeader,
'genarrative_access_session',
);
assert.ok(token);
return {
@@ -1552,7 +1747,10 @@ test('logout-all revokes all refresh sessions and invalidates existing access to
assert.equal(refreshResponse.status, 200);
const entryB = {
token: refreshPayload.token,
refreshCookie: refreshResponse.headers.get('set-cookie') || '',
refreshCookie: buildCookieHeader(
refreshResponse.headers.get('set-cookie'),
'genarrative_refresh_session',
),
};
const logoutAllResponse = await httpRequest(
@@ -1623,7 +1821,7 @@ test('error responses share one structure and preserve request ids', async () =>
assert.equal(response.status, 401);
assert.equal(payload.error.code, 'UNAUTHORIZED');
assert.equal(payload.error.message, '缺少 Authorization Bearer Token');
assert.equal(payload.error.message, '缺少登录凭证');
assert.equal(payload.meta.requestId, requestId);
assert.equal(payload.meta.apiVersion, '2026-04-08');
assert.equal(payload.meta.routeVersion, '2026-04-08');
@@ -2895,6 +3093,98 @@ test('custom world agent draft_foundation action generates draft cards and card
);
});
test('custom world agent stream message returns enriched session payload over sse', async () => {
await withTestServer(
'custom-world-agent-stream-session',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123');
const readySession = await createReadyCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const foundationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
action: 'draft_foundation',
}),
}),
);
const foundationPayload = (await foundationResponse.json()) as {
operation: {
operationId: string;
};
};
assert.equal(foundationResponse.status, 200);
await waitForCustomWorldAgentOperation({
baseUrl,
token: entry.token,
sessionId: readySession.sessionId,
operationId: foundationPayload.operation.operationId,
expectedStatus: 'completed',
});
const streamResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`,
withBearer(entry.token, {
method: 'POST',
headers: {
Accept: 'text/event-stream',
},
body: JSON.stringify({
clientMessageId: 'stream-client-1',
text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。',
focusCardId: null,
selectedCardIds: [],
}),
}),
);
const streamText = await streamResponse.text();
const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u);
assert.equal(streamResponse.status, 200);
assert.match(
streamResponse.headers.get('content-type') ?? '',
/text\/event-stream/u,
);
assert.match(streamText, /event: reply_delta/u);
assert.match(streamText, /event: session/u);
assert.match(streamText, /event: done/u);
assert.ok(sessionEventMatch?.[1]);
const sessionEvent = JSON.parse(sessionEventMatch![1]) as {
session: {
stage: string;
supportedActions?: Array<{ action: string; enabled: boolean }>;
resultPreview?: {
source: string;
preview: { name?: string };
} | null;
};
};
assert.equal(sessionEvent.session.stage, 'object_refining');
assert.equal(
sessionEvent.session.supportedActions?.some(
(entry) =>
entry.action === 'update_draft_card' && entry.enabled === true,
),
true,
);
assert.equal(
sessionEvent.session.resultPreview?.source,
'session_preview',
);
assert.ok(sessionEvent.session.resultPreview?.preview?.name);
},
);
});
test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => {
await withTestServer(
'custom-world-agent-phase3-http-not-ready',
@@ -3197,6 +3487,129 @@ test('custom world agent sync_result_profile action writes result snapshot back
);
});
test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => {
await withTestServer(
'custom-world-library-agent-publish-blocked',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_library_agent_blocked',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const profileId = `agent-draft-${session.sessionId}`;
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
error: {
code: string;
message: string;
};
};
const sessionAfterPublishAttempt =
await context.customWorldAgentOrchestrator.getSessionSnapshot(
entry.user.id,
session.sessionId,
);
assert.equal(publishResponse.status, 409);
assert.equal(publishPayload.error.code, 'CONFLICT');
assert.match(
publishPayload.error.message,
/ \d+ blocker/u,
);
assert.match(
publishPayload.error.message,
/||线/u,
);
assert.notEqual(sessionAfterPublishAttempt?.stage, 'published');
},
);
});
test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => {
await withTestServer(
'custom-world-library-agent-publish-success',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_library_agent_success',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const profileId = `agent-draft-${session.sessionId}`;
await markAgentSessionPublishReady({
context,
userId: entry.user.id,
sessionId: session.sessionId,
});
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
entry: {
profileId: string;
visibility: 'draft' | 'published';
};
};
const libraryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library`,
withBearer(entry.token),
);
const libraryPayload = (await libraryResponse.json()) as {
entries: Array<{
profileId: string;
visibility: 'draft' | 'published';
}>;
};
const sessionAfterPublish =
await context.customWorldAgentOrchestrator.getSessionSnapshot(
entry.user.id,
session.sessionId,
);
assert.equal(publishResponse.status, 200);
assert.equal(publishPayload.entry.profileId, profileId);
assert.equal(publishPayload.entry.visibility, 'published');
assert.equal(libraryResponse.status, 200);
assert.equal(
libraryPayload.entries.find((item) => item.profileId === profileId)
?.visibility,
'published',
);
assert.equal(sessionAfterPublish?.stage, 'published');
assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true);
assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true);
assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []);
assert.ok(
sessionAfterPublish?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('已正式发布'),
),
);
},
);
});
test('custom world agent generate_characters action appends character cards over http', async () => {
await withTestServer(
'custom-world-agent-phase4-generate-characters-http',