Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
@@ -122,6 +122,11 @@ function createTestConfig(
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
accessCookieName: 'genarrative_access_session',
|
||||
accessCookieTtlSeconds: 7200,
|
||||
accessCookieSecure: false,
|
||||
accessCookieSameSite: 'Lax',
|
||||
accessCookiePath: '/',
|
||||
refreshCookieName: 'genarrative_refresh_session',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
@@ -203,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);
|
||||
@@ -258,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);
|
||||
@@ -444,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(
|
||||
@@ -451,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)}`,
|
||||
@@ -467,8 +675,7 @@ async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
|
||||
const location = callbackResponse.headers.get('location') || '';
|
||||
assert.ok(location);
|
||||
const hash = parseRedirectHash(location);
|
||||
const token = hash.get('auth_token') || '';
|
||||
|
||||
const token = hash.get('auth_token')?.trim() || '';
|
||||
assert.ok(token);
|
||||
|
||||
return {
|
||||
@@ -1536,7 +1743,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(
|
||||
@@ -2503,6 +2713,34 @@ test('custom world works endpoint returns draft sessions and published worlds to
|
||||
|
||||
assert.equal(publishResponse.status, 200);
|
||||
|
||||
const publishMutationResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-published/publish`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(publishMutationResponse.status, 200);
|
||||
|
||||
const draftOnlyResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-draft-only`,
|
||||
withBearer(entry.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
id: 'world-draft-only',
|
||||
name: '旧兼容草稿',
|
||||
subtitle: '仍保留在作品库,但不再进入创作中心',
|
||||
summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。',
|
||||
playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }],
|
||||
landmarks: [{ id: 'port-draft', name: '旧草稿地点' }],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(draftOnlyResponse.status, 200);
|
||||
|
||||
const worksResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/works`,
|
||||
{
|
||||
@@ -2542,6 +2780,10 @@ test('custom world works endpoint returns draft sessions and published worlds to
|
||||
item.canEnterWorld === true,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
worksPayload.items.some((item) => item.profileId === 'world-draft-only'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2847,6 +3089,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',
|
||||
@@ -3038,6 +3372,240 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
);
|
||||
});
|
||||
|
||||
test('custom world agent sync_result_profile action writes result snapshot back over http', async () => {
|
||||
await withTestServer(
|
||||
'custom-world-agent-sync-result-profile-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_sync_result',
|
||||
'secret123',
|
||||
);
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
});
|
||||
|
||||
const actionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'sync_result_profile',
|
||||
profile: {
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '潮雾列岛·结果页回写版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '结果页里的最新世界概述已经回写到当前草稿。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船夜与假航灯背后的操盘链。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛·结果页回写版',
|
||||
settingSummary: '测试',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const actionPayload = (await actionResponse.json()) as {
|
||||
operation: {
|
||||
operationId: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(actionResponse.status, 200);
|
||||
assert.equal(actionPayload.operation.status, 'queued');
|
||||
|
||||
await waitForCustomWorldAgentOperation({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
sessionId: session.sessionId,
|
||||
operationId: actionPayload.operation.operationId,
|
||||
expectedStatus: 'completed',
|
||||
});
|
||||
|
||||
const sessionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const sessionPayload = (await sessionResponse.json()) as {
|
||||
draftProfile: {
|
||||
name?: string;
|
||||
summary?: string;
|
||||
legacyResultProfile?: {
|
||||
name?: string;
|
||||
playerGoal?: string;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版');
|
||||
assert.equal(
|
||||
sessionPayload.draftProfile?.summary,
|
||||
'结果页里的最新世界概述已经回写到当前草稿。',
|
||||
);
|
||||
assert.equal(
|
||||
sessionPayload.draftProfile?.legacyResultProfile?.name,
|
||||
'潮雾列岛·结果页回写版',
|
||||
);
|
||||
assert.equal(
|
||||
sessionPayload.draftProfile?.legacyResultProfile?.playerGoal,
|
||||
'查清沉船夜与假航灯背后的操盘链。',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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