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