合并 origin/master 到作品管理分支
This commit is contained in:
@@ -5,12 +5,16 @@ import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminGameReview,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
listAdminRechargeOrders,
|
||||
moderateAdminGameReview,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
restoreAdminGameDistributionGame,
|
||||
@@ -27,6 +31,52 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('用户评价查询编码筛选和目标,管理请求携带原创建时间及幂等key', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await listAdminGameReviews('token', {
|
||||
gameId: 'game+1',
|
||||
userId: 'user/1',
|
||||
keyword: '中文 广告',
|
||||
status: 'hidden',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews?gameId=game%2B1&userId=user%2F1&keyword=%E4%B8%AD%E6%96%87+%E5%B9%BF%E5%91%8A&status=hidden&page=2&pageSize=20',
|
||||
);
|
||||
await listAdminGameReviewGames('token', { query: ' 中文 ', page: 1 });
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-review-games?query=%E4%B8%AD%E6%96%87&page=1',
|
||||
);
|
||||
await getAdminGameReview('token', '6:game/1user');
|
||||
expect(fetchMock.mock.calls[2]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user',
|
||||
);
|
||||
const body = {
|
||||
action: 'delete' as const,
|
||||
expectedCreatedAt: '2026-10-01T00:00:00Z',
|
||||
reason: '广告',
|
||||
};
|
||||
await moderateAdminGameReview('token', '6:game/1user', 'same-attempt', body);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user/moderation',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer token',
|
||||
'Idempotency-Key': 'same-attempt',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -39,6 +39,13 @@ import type {
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewGamesQuery,
|
||||
AdminGameReviewGamesResponse,
|
||||
AdminGameReviewModerationRequest,
|
||||
AdminGameReviewModerationResponse,
|
||||
AdminGameReviewsQuery,
|
||||
AdminGameReviewsResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -1250,6 +1257,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
* 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数,
|
||||
* 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。
|
||||
*/
|
||||
|
||||
function gameReviewQuery(
|
||||
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && String(value).trim())
|
||||
params.set(key, String(value).trim());
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function listAdminGameReviewGames(
|
||||
token: string,
|
||||
query: AdminGameReviewGamesQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewGamesResponse>(
|
||||
`/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameReviews(
|
||||
token: string,
|
||||
query: AdminGameReviewsQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewsResponse>(
|
||||
`/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameReview(token: string, reviewId: string) {
|
||||
return request<AdminGameReviewDetailResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function moderateAdminGameReview(
|
||||
token: string,
|
||||
reviewId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameReviewModerationRequest,
|
||||
) {
|
||||
return request<AdminGameReviewModerationResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}/moderation`,
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: {
|
||||
|
||||
@@ -1076,6 +1076,10 @@ export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc';
|
||||
export type AdminAgcModelProtocol =
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'anthropic';
|
||||
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
@@ -1083,6 +1087,8 @@ export interface AdminAgcModel {
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
agentMode: AdminAgcAgentMode;
|
||||
/** 上游线协议,与执行器正交。 */
|
||||
protocol?: AdminAgcModelProtocol;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
@@ -1197,6 +1203,90 @@ export interface AdminGameDistributionRestoreResponse {
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
/** 游戏用户评价管理,与发行版本审核分开。 */
|
||||
export interface AdminGameReviewGamesQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGame {
|
||||
gameId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGamesResponse {
|
||||
games: AdminGameReviewGame[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsQuery {
|
||||
gameId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
status?: 'all' | 'visible' | 'hidden';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGameInfo {
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReview {
|
||||
id: string;
|
||||
gameId: string;
|
||||
game: AdminGameReviewGameInfo;
|
||||
author: { id: string; name: string; avatarUrl?: string | null };
|
||||
score: number;
|
||||
comment: string;
|
||||
isHidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewOperation {
|
||||
id: string;
|
||||
reviewId: string;
|
||||
gameId: string;
|
||||
userId: string;
|
||||
reviewCreatedAt: string;
|
||||
action: string;
|
||||
adminUserId: string;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsResponse {
|
||||
reviews: AdminGameReview[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewDetailResponse {
|
||||
review: AdminGameReview;
|
||||
operations: AdminGameReviewOperation[];
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationRequest {
|
||||
action: 'hide' | 'restore' | 'delete';
|
||||
expectedCreatedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationResponse {
|
||||
review: AdminGameReview | null;
|
||||
operation: AdminGameReviewOperation;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseRevie
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -141,7 +142,7 @@ export function AdminApp() {
|
||||
|
||||
setRouteId(nextRouteId);
|
||||
const nextHash = routeHash(nextRouteId);
|
||||
if (window.location.hash !== nextHash) {
|
||||
if ((window.location.hash.split('?')[0] ?? '').toLowerCase() !== nextHash) {
|
||||
window.history.replaceState(null, '', nextHash);
|
||||
}
|
||||
}, [accessibleRoutes, admin, routeId, status]);
|
||||
@@ -325,6 +326,15 @@ export function AdminApp() {
|
||||
) : null}
|
||||
{activeRouteId === 'game-management' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
canManageReviews={accessibleRoutes.some(
|
||||
(route) => route.id === 'game-reviews',
|
||||
)}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-reviews' ? (
|
||||
<AdminGameReviewsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,7 @@ const routeIcons = {
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-reviews'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['game-reviews']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-management'],
|
||||
}).some((route) => route.id === 'game-reviews'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminRouteId =
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -62,6 +63,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -104,3 +104,52 @@ test('new models default to Codex and save the selected Agent mode', async () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{
|
||||
id: 'quality',
|
||||
alias: '高质量',
|
||||
modelId: 'claude-opus-5-5',
|
||||
enabled: true,
|
||||
agentMode: 'cc' as const,
|
||||
protocol: 'anthropic' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('claude-opus-5-5');
|
||||
|
||||
const agentMode = screen.getByLabelText(
|
||||
'模型 1 Agent 模式',
|
||||
) as HTMLSelectElement;
|
||||
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
|
||||
expect(agentMode.value).toBe('cc');
|
||||
expect(protocol.value).toBe('anthropic');
|
||||
// 执行器下拉里不再混入协议取值。
|
||||
expect(Array.from(agentMode.options).map((option) => option.value)).toEqual([
|
||||
'codex',
|
||||
'cc',
|
||||
]);
|
||||
|
||||
fireEvent.change(agentMode, { target: { value: 'codex' } });
|
||||
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
|
||||
const payload = calls[calls.length - 1]?.[1];
|
||||
expect(payload?.models[0]).toMatchObject({
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
|
||||
{ key: 'alias', label: '别名' },
|
||||
{ key: 'modelId', label: '实际模型名' },
|
||||
{ key: 'agentMode', label: 'Agent 模式' },
|
||||
{ key: 'protocol', label: '协议' },
|
||||
{ key: 'enabled', label: '启用' },
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'actions', label: '操作' },
|
||||
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_responses',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -228,6 +230,22 @@ export function AdminAgcModelsPage({
|
||||
<option value="cc">Claude Code</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`模型 ${index + 1} 协议`}
|
||||
value={model.protocol ?? 'openai_responses'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
protocol: e.target.value as AdminAgcModel['protocol'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai_responses">OpenAI Responses</option>
|
||||
<option value="openai_chat">OpenAI Chat Completions</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
|
||||
@@ -138,6 +138,20 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('有评价权限时游戏管理提供带gameId的评价入口', async () => {
|
||||
window.history.replaceState(null, '', '#game-management');
|
||||
render(
|
||||
<AdminGameManagementPage
|
||||
token="token"
|
||||
canManageReviews
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看评价' }));
|
||||
expect(window.location.hash).toBe('#game-reviews?gameId=game_1');
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -31,6 +31,7 @@ import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameManagementPageProps {
|
||||
token: string;
|
||||
canManageReviews?: boolean;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ function createGameActionIdempotencyKey(
|
||||
|
||||
export function AdminGameManagementPage({
|
||||
token,
|
||||
canManageReviews = false,
|
||||
onUnauthorized,
|
||||
}: AdminGameManagementPageProps) {
|
||||
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
|
||||
@@ -550,6 +552,17 @@ export function AdminGameManagementPage({
|
||||
>
|
||||
版本历史
|
||||
</AdminButton>
|
||||
{canManageReviews ? (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`;
|
||||
}}
|
||||
>
|
||||
查看评价
|
||||
</AdminButton>
|
||||
) : null}
|
||||
</AdminActionRow>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminGameReview,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
moderateAdminGameReview,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameReview,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewsResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameReviewsPage } from './AdminGameReviewsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
listAdminGameReviews: vi.fn(),
|
||||
listAdminGameReviewGames: vi.fn(),
|
||||
getAdminGameReview: vi.fn(),
|
||||
moderateAdminGameReview: vi.fn(),
|
||||
isAdminApiError: (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'status' in error,
|
||||
formatAdminApiError: (error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
}));
|
||||
|
||||
const review: AdminGameReview = {
|
||||
id: '6:game_1user_1',
|
||||
gameId: 'game_1',
|
||||
game: { title: '测试游戏', status: 'published' },
|
||||
author: { id: 'user_1', name: '用户甲' },
|
||||
score: 8,
|
||||
comment: '<b>中文😀</b>\n完整正文',
|
||||
isHidden: false,
|
||||
createdAt: '2026-10-01T01:00:00Z',
|
||||
updatedAt: '2026-10-01T01:00:00Z',
|
||||
};
|
||||
function list(
|
||||
overrides: Partial<AdminGameReviewsResponse> = {},
|
||||
): AdminGameReviewsResponse {
|
||||
return {
|
||||
reviews: [review],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const unauthorized = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.history.replaceState(null, '', '#game-reviews');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(list());
|
||||
vi.mocked(listAdminGameReviewGames).mockResolvedValue({
|
||||
games: [{ gameId: 'game_2', title: '第二个游戏', status: 'suspended' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({ review, operations: [] });
|
||||
vi.mocked(moderateAdminGameReview).mockResolvedValue({
|
||||
review: { ...review, isHidden: true },
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
function show() {
|
||||
return render(
|
||||
<AdminGameReviewsPage token="token" onUnauthorized={unauthorized} />,
|
||||
);
|
||||
}
|
||||
async function row() {
|
||||
return (await screen.findByText('用户甲')).closest('tr')!;
|
||||
}
|
||||
|
||||
test('入口游戏参数和hash变化生效,组合筛选分页在服务端查询,重置回第一页', async () => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_1', page: 1 }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('评价用户 ID'), {
|
||||
target: { value: 'user_1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评论关键词'), {
|
||||
target: { value: '中文' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评价状态'), {
|
||||
target: { value: 'hidden' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: 'game_1',
|
||||
userId: 'user_1',
|
||||
keyword: '中文',
|
||||
status: 'hidden',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2, keyword: '中文' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: '',
|
||||
userId: '',
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2', page: 1 }),
|
||||
),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
});
|
||||
|
||||
test('名称查找包含下架游戏,选择后按游戏ID筛选', async () => {
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(screen.getByRole('button', { name: '按名称查找游戏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '查找游戏' });
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.change(within(dialog).getByLabelText('游戏名称或 ID'), {
|
||||
target: { value: '第二个' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '搜索' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviewGames).toHaveBeenLastCalledWith('token', {
|
||||
query: '第二个',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择' }));
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('隐藏原因必填,失败保留草稿,同次重试复用幂等key,取消不写入', async () => {
|
||||
vi.mocked(moderateAdminGameReview).mockRejectedValueOnce(
|
||||
new Error('网络失败'),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
let dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
expect(
|
||||
await within(dialog).findByText('原因必须为 1–4000 个字符'),
|
||||
).toBeTruthy();
|
||||
expect(moderateAdminGameReview).not.toHaveBeenCalled();
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: ' 广告😀 ' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await within(dialog).findByText('网络失败');
|
||||
expect(
|
||||
(within(dialog).getByLabelText('操作原因(必填)') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe(' 广告😀 ');
|
||||
const first = vi.mocked(moderateAdminGameReview).mock.calls[0]!;
|
||||
expect(first[3]).toEqual({
|
||||
action: 'hide',
|
||||
expectedCreatedAt: review.createdAt,
|
||||
reason: '广告😀',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await screen.findByText('评价已隐藏');
|
||||
expect(vi.mocked(moderateAdminGameReview).mock.calls[1]![2]).toBe(first[2]);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
expect(
|
||||
within(dialog).getByText('删除后不可恢复,用户可以重新评价'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('4001码点原因不能提交,恢复复用确认且不要求原因', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [{ ...review, isHidden: true }] }),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const deletion = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(deletion).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '😀'.repeat(4001) },
|
||||
});
|
||||
expect(within(deletion).getByText('4001/4000')).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(deletion).getByRole('button', {
|
||||
name: '确认删除',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
fireEvent.click(within(deletion).getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '恢复' }));
|
||||
const confirmation = screen.getByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||||
await waitFor(() =>
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledWith(
|
||||
'token',
|
||||
review.id,
|
||||
expect.any(String),
|
||||
{ action: 'restore', expectedCreatedAt: review.createdAt },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('删除使当前页越界时回到最后一页并保留条件', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockImplementation(async (_token, query) =>
|
||||
list({ page: query?.page ?? 1, total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2 }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '重复内容' },
|
||||
});
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 20, totalPages: 1 }),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 1 }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('评价已删除')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('详情完整纯文本只读并展示后台操作原因', async () => {
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({
|
||||
review,
|
||||
operations: [
|
||||
{
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin_1',
|
||||
reason: '广告内容',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: '评价详情' });
|
||||
expect(await within(dialog).findByText('广告内容')).toBeTruthy();
|
||||
expect(dialog.textContent).toContain(review.comment);
|
||||
expect(dialog.querySelector('b')).toBeNull();
|
||||
expect(within(dialog).getByText('admin_1')).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('textbox')).toBeNull();
|
||||
});
|
||||
|
||||
test('详情关闭后旧响应不会覆盖新目标', async () => {
|
||||
const second = {
|
||||
...review,
|
||||
id: 'review_2',
|
||||
author: { id: 'user_2', name: '用户乙' },
|
||||
comment: '第二条正文',
|
||||
};
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [review, second], total: 2 }),
|
||||
);
|
||||
let resolveOld!: (value: { review: AdminGameReview; operations: [] }) => void;
|
||||
vi.mocked(getAdminGameReview)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminGameReviewDetailResponse>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ review: second, operations: [] });
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByText('用户乙').closest('tr')!).getByRole('button', {
|
||||
name: '查看详情',
|
||||
}),
|
||||
);
|
||||
await within(screen.getByRole('dialog')).findByText('第二条正文');
|
||||
await act(async () => {
|
||||
resolveOld({ review, operations: [] });
|
||||
});
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText('第二条正文'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('dialog').textContent).not.toContain(review.comment);
|
||||
});
|
||||
|
||||
test('管理提交中切换游戏,成功后刷新当前游戏而不恢复旧筛选', async () => {
|
||||
let finish!: (
|
||||
value: Awaited<ReturnType<typeof moderateAdminGameReview>>,
|
||||
) => void;
|
||||
vi.mocked(moderateAdminGameReview).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof moderateAdminGameReview>>>(
|
||||
(resolve) => {
|
||||
finish = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '广告' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
await within(screen.getByRole('dialog', { name: '评价详情' })).findByText(
|
||||
'评价 ID',
|
||||
);
|
||||
await act(async () => {
|
||||
finish({
|
||||
review: null,
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: '评价详情' })).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -766,6 +766,33 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-comment {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-table .admin-review-comment {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-review-comment-summary {
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
|
||||
@@ -139,6 +139,7 @@ const allowedUncalledTauriCommands = [
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
'read_profile_recharge_center',
|
||||
'read_profile_wallet_ledger',
|
||||
'read_profile_referral_invite_center',
|
||||
'create_profile_recharge_order',
|
||||
'confirm_wechat_profile_recharge_order',
|
||||
'redeem_profile_reward_code',
|
||||
|
||||
@@ -156,7 +156,8 @@ function resolveTestExecutable() {
|
||||
kind: options.targetKind,
|
||||
name: options.binName,
|
||||
});
|
||||
cargoArguments.push('--no-run', '--message-format=json');
|
||||
// 保留 stdout 的 artifact JSON,并由 Cargo 将编译诊断输出到继承的 stderr。
|
||||
cargoArguments.push('--no-run', '--message-format=json-render-diagnostics');
|
||||
console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`);
|
||||
|
||||
const child = spawn('cargo', cargoArguments, {
|
||||
@@ -447,7 +448,12 @@ async function runWithConcurrency(shards, runner) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractFailingTestNames(result) {
|
||||
// 只认**本片名单里真实存在**的用例名:先按 `---- <name> stdout ----` / 失败清单行归一化,
|
||||
// 再拿名单做精确匹配。不要再用名字前缀白名单猜用例(历史上只放行 `tests::` 与
|
||||
// `process_session::tests::`,而 bin 目标的用例名形如 `agent::thread_manager::tests::…`,
|
||||
// 于是绝大多数用例永远进不了复核,偶发假红就直接判红)。
|
||||
function extractFailingTestNames(result, shardTestNames) {
|
||||
const known = new Set(shardTestNames);
|
||||
const names = new Set();
|
||||
for (const line of result.failures) {
|
||||
const normalized = line
|
||||
@@ -457,11 +463,8 @@ function extractFailingTestNames(result) {
|
||||
.replace(/\s*stdout\s*----$/, '')
|
||||
.replace(/\s*\(\d+\)\s*$/, '')
|
||||
.trim();
|
||||
const match = normalized.match(
|
||||
/^(process_session::tests::[A-Za-z0-9_:]+|tests::[A-Za-z0-9_:]+)$/,
|
||||
);
|
||||
if (match) {
|
||||
names.add(match[1]);
|
||||
if (known.has(normalized)) {
|
||||
names.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
@@ -518,9 +521,7 @@ async function main() {
|
||||
}
|
||||
// 片内串行的时序型用例在高负载 CI 上会偶发假红。只对失败用例做一次
|
||||
// 有界复核:复核通过按 flaky 记录,复核失败才判红,避免把真实回归洗掉。
|
||||
const failingTestNames = extractFailingTestNames(result).filter((name) =>
|
||||
shardTestNames.includes(name),
|
||||
);
|
||||
const failingTestNames = extractFailingTestNames(result, shardTestNames);
|
||||
if (failingTestNames.length === 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -531,10 +532,13 @@ async function main() {
|
||||
failingTestNames,
|
||||
);
|
||||
if (!retry.ok) {
|
||||
// 复核也失败就判红,但**两次的现场都留**:首次是整片的上下文,复核只跑失败用例,
|
||||
// 丢掉首次现场会让"到底整片跑成什么样"这件事在日志里消失。
|
||||
return {
|
||||
...result,
|
||||
failures: [
|
||||
`re-run of ${failingTestNames.length} failing test(s) also failed`,
|
||||
...result.failures,
|
||||
...retry.failures,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -62,9 +62,43 @@ fn failing_case() {
|
||||
assert.match(result.output, /1 passed; 1 failed/);
|
||||
});
|
||||
|
||||
test('compile failure retains the diagnostic code, source location and cause', (t) => {
|
||||
const result = runFixture(
|
||||
t,
|
||||
'#[test]\nfn compile_failure() { let _: u32 = "not a number"; }\n',
|
||||
);
|
||||
assert.equal(result.status, 1, result.output);
|
||||
assert.match(result.output, /error\[E0308\]/);
|
||||
assert.match(result.output, /src[\\/]lib\.rs:2:/);
|
||||
assert.match(result.output, /mismatched types/);
|
||||
assert.match(result.output, /expected `u32`, found `&str`/);
|
||||
});
|
||||
|
||||
test('successful shard keeps its compact summary', (t) => {
|
||||
const result = runFixture(t, '#[test]\nfn passing_case() {}\n');
|
||||
assert.equal(result.status, 0, result.output);
|
||||
assert.match(result.output, /shard 1\/1 ok: 1 test\(s\)/);
|
||||
assert.doesNotMatch(result.output, /test passing_case \.\.\. ok/);
|
||||
});
|
||||
|
||||
// bin 目标的用例名带命名空间(`agent::thread_manager::tests::…`)。复核名单必须靠
|
||||
// 「本片名单精确匹配」认用例,不能靠名字前缀白名单,否则这类用例永远进不了重跑。
|
||||
test('namespaced failing test is re-run once before the shard is judged', (t) => {
|
||||
const result = runFixture(
|
||||
t,
|
||||
`
|
||||
mod agent {
|
||||
pub mod thread_manager {
|
||||
#[test]
|
||||
fn flaky_case() {
|
||||
assert_eq!(1, 2, "namespaced panic evidence");
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
assert.equal(result.status, 1, result.output);
|
||||
assert.match(result.output, /re-run of 1 failing test\(s\) also failed/);
|
||||
assert.match(result.output, /agent::thread_manager::flaky_case/);
|
||||
assert.match(result.output, /namespaced panic evidence/);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"agc_list_project_files.description": "列出当前 AGC 项目文件,包括尚未登记的本地资源。结果返回项目相对路径、大小、文件类别、是否已登记及 assetImportable。仅把 assetImportable=true 的项目相对路径交给 agc_import_account_assets.localPaths。",
|
||||
"agc_list_project_files.parameters.path": "可选的项目相对目录前缀,例如 assets 或 game",
|
||||
"agc_list_project_files.parameters.kind": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源",
|
||||
"agc_list_account_assets.description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片。结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源用 agc_list_project_files 发现。",
|
||||
"agc_list_account_assets.description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片。可按 folderId 与 query(素材名或文件夹名)筛选;offset 为非负整数,limit 为 1–100(默认 100),按 nextOffset 继续翻页。账户素材总量不影响查询。结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源用 agc_list_project_files 发现。",
|
||||
"agc_import_account_assets.description": "导入账户图片、已绑定网页项目画布图片或项目内本地资源。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地资源使用 agc_list_project_files 返回且 assetImportable=true 的项目根相对 localPaths。assetIds 与 localPaths 可混合提交。",
|
||||
"agc_create_or_derive_resource.description": "按用户当前意图创建或派生视频、角色动画、音效或背景音乐。相同未完成请求会优先恢复;付费请求结果不确定时先核对状态,禁止重发。",
|
||||
"agc_create_or_derive_resource.parameters.sourceLocalAssetId": "derive 时必须使用 agc_list_registered_assets 返回的当前项目 localAssetId",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"file_tools_contract": "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n",
|
||||
"task_tools_contract": "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n",
|
||||
"canvas_generate_contract": "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n",
|
||||
"asset_library_contract": "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n",
|
||||
"asset_library_contract": "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;offset 为非负整数,limit 为 1–100(默认 100),按 nextOffset 继续翻页,账户素材总量不影响查询。结果只含当前页 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n",
|
||||
"asset_import_contract": "canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对已识别资源路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后资源即可用于正式产物。\n",
|
||||
"collaboration_tools_contract": "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n",
|
||||
"empty_tool_inputs_contract": "只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};asset.library.list 也允许使用其广告 schema 中的全 null/分页默认值;其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。",
|
||||
|
||||
@@ -8,10 +8,12 @@ use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime::{
|
||||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
|
||||
ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
@@ -103,6 +105,29 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
|
||||
/// 客户端邀请码弹层需要的完整数据。
|
||||
///
|
||||
/// 后端只返回站内相对路径(`inviteLinkPath`),客户端要拿它当分享链接用,
|
||||
/// 所以这里用当前登录站点 origin 补全成完整 URL 再交给渲染层。
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClientProfileReferralInviteCenter {
|
||||
pub(crate) center: ProfileReferralInviteCenterResponse,
|
||||
pub(crate) invite_url: String,
|
||||
}
|
||||
|
||||
fn resolve_invite_url(api_base_url: &str, invite_link_path: &str) -> String {
|
||||
if invite_link_path.trim().is_empty() {
|
||||
return api_base_url.trim_end_matches('/').to_string();
|
||||
}
|
||||
match Url::parse(&format!("{}/", api_base_url.trim_end_matches('/')))
|
||||
.and_then(|base| base.join(invite_link_path))
|
||||
{
|
||||
Ok(url) => url.to_string(),
|
||||
Err(_) => invite_link_path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_json<T: DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
@@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result<ProfileWalletLedgerRe
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_profile_referral_invite_center(
|
||||
) -> Result<ClientProfileReferralInviteCenter, String> {
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
let center: ProfileReferralInviteCenterResponse = request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::GET,
|
||||
&["api", "profile", "referrals", "invite-center"],
|
||||
None,
|
||||
"读取邀请码失败",
|
||||
)
|
||||
.await?;
|
||||
let invite_url = resolve_invite_url(&snapshot.api_base_url, ¢er.invite_link_path);
|
||||
Ok(ClientProfileReferralInviteCenter { center, invite_url })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_profile_recharge_order(
|
||||
product_id: String,
|
||||
@@ -314,4 +357,20 @@ mod tests {
|
||||
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
|
||||
assert!(bounded_business_id("bad\nid", "订单").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_link_resolves_against_login_origin() {
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world", "/?inviteCode=SY12345678"),
|
||||
"https://dev.genarrative.world/?inviteCode=SY12345678"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world/", "invite/SY12345678"),
|
||||
"https://dev.genarrative.world/invite/SY12345678"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world", ""),
|
||||
"https://dev.genarrative.world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,18 @@ const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE";
|
||||
const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200;
|
||||
/// 超时收口时给 sidecar stderr 的排空窗口。
|
||||
///
|
||||
/// sidecar 自己还活着时 stderr 永远等不到 EOF(Claude Code CLI 正在网关侧重试),
|
||||
/// 没有上限就会让超时分支自己挂住:这一轮既写不出终态,也留不下任何诊断
|
||||
/// (2026-10-02 实测:回合 4 分钟不结束、应用日志一行不写)。
|
||||
const CLAUDE_CODE_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// sidecar stdout 静默多久就写一条"还没收到事件"的应用日志。
|
||||
///
|
||||
/// Claude Code CLI 在网关侧失败时会自己退避重试,这段时间**一行事件都没有**;
|
||||
/// 不留痕的话用户与离线诊断只能看到一个卡住的回合(真实案例:ECONNRESET 重试,
|
||||
/// 每轮 ~60 秒,共 10 轮)。
|
||||
const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
struct ActiveClaudeCodeTurn {
|
||||
client_turn_id: String,
|
||||
@@ -212,15 +224,30 @@ async fn run_sidecar_turn(
|
||||
.take()
|
||||
.ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?;
|
||||
let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES));
|
||||
let result = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
|
||||
let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let mut events = Vec::new();
|
||||
let mut terminal = None;
|
||||
while let Some(line) = lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|error| format!("读取 Claude Agent SDK sidecar 输出失败:{error}"))?
|
||||
{
|
||||
let mut idle_rounds = 0_u64;
|
||||
loop {
|
||||
let line =
|
||||
match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line())
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(line))) => line,
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(error)) => {
|
||||
return Err(format!("读取 Claude Agent SDK sidecar 输出失败:{error}"));
|
||||
}
|
||||
Err(_) => {
|
||||
idle_rounds += 1;
|
||||
app_log!(
|
||||
"agent.direct_codex.claude_sidecar_idle idleSeconds={}",
|
||||
CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -237,6 +264,7 @@ async fn run_sidecar_turn(
|
||||
break;
|
||||
}
|
||||
Some("error") => {
|
||||
kill_claude_code_process_tree(pid);
|
||||
return Err(value
|
||||
.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -257,7 +285,27 @@ async fn run_sidecar_turn(
|
||||
Ok(SidecarTurnResult { events, result })
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Agent SDK sidecar 回合超时".to_string())??;
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Err(_) => {
|
||||
// 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着,
|
||||
// stderr 等不到 EOF,这一轮就永远出不了终态。
|
||||
kill_claude_code_process_tree(pid);
|
||||
let stderr_detail =
|
||||
match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await {
|
||||
Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(),
|
||||
_ => None,
|
||||
}
|
||||
.map(|value| value.trim().chars().take(512).collect::<String>())
|
||||
.filter(|value| !value.is_empty())
|
||||
// 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。
|
||||
.filter(|value| !value.contains("EPIPE"));
|
||||
let detail = stderr_detail
|
||||
.map(|value| format!(":{value}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!("Claude Agent SDK sidecar 回合超时{detail}"));
|
||||
}
|
||||
};
|
||||
let _ = stderr_task.await;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -299,7 +347,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Claude Agent SDK sidecar 的 Node runtime 不可用".to_string())?;
|
||||
.map_err(|error| {
|
||||
// `output()` 失败只说明进程没能启动(可执行文件缺失、权限不足或被拦截),
|
||||
// 不能笼统说成「Node runtime 不可用」并丢掉 OS 错误。
|
||||
format!("Claude Agent SDK sidecar 的 Node runtime 启动失败:{error}")
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err("Claude Agent SDK sidecar 的 Node runtime 版本检查失败".to_string());
|
||||
}
|
||||
@@ -318,6 +370,14 @@ pub(crate) fn game_creator_claude_code_cli_route_error(
|
||||
if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS {
|
||||
return Some("cc 模式需要有效的 Claude Code 模型标识".to_string());
|
||||
}
|
||||
if llm.api_key.trim().is_empty()
|
||||
&& !llm.custom_enabled
|
||||
&& std::env::var_os("ANTHROPIC_API_KEY").is_none()
|
||||
&& std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none()
|
||||
&& crate::platform_session::current_platform_session().is_none()
|
||||
{
|
||||
return Some("cc 模式需要先登录陶泥儿账号".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -330,7 +390,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) {
|
||||
fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option<String> {
|
||||
std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.or_else(|| llm.map(|value| value.base_url.trim_end_matches('/').to_string()))
|
||||
.or_else(|| {
|
||||
let llm = llm?;
|
||||
// Official models never talk to the Router directly: the account
|
||||
// Router credential lives server-side, so the Claude sidecar goes
|
||||
// through the platform gateway and appends `/v1/messages` itself.
|
||||
if !llm.custom_enabled {
|
||||
if let Some(session) = crate::platform_session::current_platform_session() {
|
||||
return Some(format!(
|
||||
"{}/api/llm/anthropic",
|
||||
session.api_base_url.trim_end_matches('/')
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(llm.base_url.trim_end_matches('/').to_string())
|
||||
})
|
||||
.map(|value| {
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
@@ -386,12 +460,44 @@ fn configure_claude_code_environment(
|
||||
{
|
||||
command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim());
|
||||
}
|
||||
// Official AGC model entries use the account session rather than a
|
||||
// user-supplied API key. The Claude SDK only reads Anthropic auth
|
||||
// from its child-process environment, so bridge the already-held
|
||||
// platform token without persisting or exposing it in argv/logs.
|
||||
if llm.api_key.trim().is_empty()
|
||||
&& !llm.custom_enabled
|
||||
&& std::env::var_os("ANTHROPIC_API_KEY").is_none()
|
||||
&& std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none()
|
||||
{
|
||||
if let Some(session) = crate::platform_session::current_platform_session() {
|
||||
command.env("ANTHROPIC_AUTH_TOKEN", session.access_token);
|
||||
}
|
||||
}
|
||||
if !llm.model.trim().is_empty() {
|
||||
command.env("ANTHROPIC_MODEL", llm.model.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_result_error_detail(value: &serde_json::Value) -> Option<String> {
|
||||
let candidates = [
|
||||
value.get("result").and_then(serde_json::Value::as_str),
|
||||
value.get("error").and_then(serde_json::Value::as_str),
|
||||
value.get("message").and_then(serde_json::Value::as_str),
|
||||
value
|
||||
.get("errors")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(serde_json::Value::as_str),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(str::trim)
|
||||
.find(|detail| !detail.is_empty())
|
||||
.map(|detail| detail.chars().take(512).collect())
|
||||
}
|
||||
|
||||
fn configure_claude_code_process(command: &mut tokio::process::Command) {
|
||||
super::codex_cli::configure_game_creator_codex_cli_process(command);
|
||||
}
|
||||
@@ -496,9 +602,12 @@ fn parse_claude_code_result(
|
||||
platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string())
|
||||
})?;
|
||||
if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
return Err(platform_llm::LlmError::Transport(
|
||||
"Claude Code 返回失败终态".to_string(),
|
||||
));
|
||||
let detail = claude_result_error_detail(&value)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"Claude Code 返回失败终态{detail}"
|
||||
)));
|
||||
}
|
||||
let response_id = value
|
||||
.get("session_id")
|
||||
@@ -657,7 +766,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
client_turn_id: Option<&str>,
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
) -> Result<String, String> {
|
||||
direct_turn_trace("claude-executor-enter");
|
||||
let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?;
|
||||
direct_turn_trace("claude-mcp-loopback-ready");
|
||||
let _mcp_guard = ExternalMcpStopGuard;
|
||||
let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?;
|
||||
std::fs::create_dir_all(&home)
|
||||
@@ -667,6 +778,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|sessions| sessions.get(&session_key).cloned());
|
||||
direct_turn_trace("claude-sidecar-start");
|
||||
let payload = serde_json::json!({
|
||||
"type": "turn",
|
||||
"id": client_turn_id.unwrap_or("claude-direct-turn"),
|
||||
@@ -694,6 +806,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
client_turn_id,
|
||||
)
|
||||
.await?;
|
||||
direct_turn_trace("claude-sidecar-done");
|
||||
if let Some(session_id) = sidecar
|
||||
.result
|
||||
.get("session_id")
|
||||
@@ -710,6 +823,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
.filter_map(|event| serde_json::to_string(&event).ok())
|
||||
.collect::<Vec<_>>();
|
||||
stream.push(String::new());
|
||||
direct_turn_trace("claude-parse-start");
|
||||
parse_direct_stream_result(stream.join("\n").as_bytes(), observer)
|
||||
}
|
||||
|
||||
@@ -749,7 +863,10 @@ fn parse_direct_stream_result(
|
||||
}
|
||||
} else if event_type == Some("result") {
|
||||
if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
return Err("Claude Code 返回失败终态".to_string());
|
||||
let detail = claude_result_error_detail(&event)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!("Claude Code 返回失败终态{detail}"));
|
||||
}
|
||||
result = event
|
||||
.get("result")
|
||||
@@ -804,6 +921,16 @@ mod tests {
|
||||
assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_claude_terminal_error_detail() {
|
||||
let error = parse_claude_code_result(
|
||||
br#"{"is_error":true,"result":"Authentication failed"}"#,
|
||||
&request(false),
|
||||
)
|
||||
.expect_err("failed terminal must be rejected");
|
||||
assert!(error.to_string().contains("Authentication failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_direct_stream_result_and_final_text() {
|
||||
let mut observed = Vec::new();
|
||||
@@ -822,4 +949,15 @@ mod tests {
|
||||
[DirectCodexTurnObservation::AgentMessageSegment(_)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_direct_stream_terminal_error_detail() {
|
||||
let error = parse_direct_stream_result(
|
||||
br#"{"type":"result","is_error":true,"result":"Authentication failed"}
|
||||
"#,
|
||||
None,
|
||||
)
|
||||
.expect_err("failed terminal must be rejected");
|
||||
assert!(error.contains("Authentication failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1971,6 +1971,28 @@ fn configure_game_creator_codex_app_server_command(
|
||||
)
|
||||
}
|
||||
|
||||
/// Codex 的 `wire_api` 取自所选模型的线协议。
|
||||
///
|
||||
/// 单测不读全局配置:用例里没有真实的模型选择,读它会引入环境依赖,所以测试态固定
|
||||
/// `responses`,真实构建按 `selected_model_protocol` 在 `responses` / `chat` 间切换。
|
||||
fn game_creator_codex_provider_wire_api() -> &'static str {
|
||||
#[cfg(test)]
|
||||
{
|
||||
"responses"
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
match crate::config::load_game_creator_app_config()
|
||||
.ok()
|
||||
.and_then(|config| config.selected_model_protocol)
|
||||
.as_deref()
|
||||
{
|
||||
Some("openai_chat") => "chat",
|
||||
_ => "responses",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
command: &mut tokio::process::Command,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
@@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
let provider_token = provider_proxy
|
||||
.map(CodexProviderProxy::downstream_bearer_token)
|
||||
.unwrap_or(legacy_api_key);
|
||||
// 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions,
|
||||
// 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。
|
||||
let wire_api = game_creator_codex_provider_wire_api();
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
@@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}",
|
||||
quoted_toml_string("responses")?
|
||||
quoted_toml_string(wire_api)?
|
||||
))
|
||||
.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token);
|
||||
}
|
||||
@@ -2351,7 +2376,7 @@ impl CodexAppServerConnection {
|
||||
)
|
||||
})?;
|
||||
effective_llm.base_url =
|
||||
format!("{}/api/llm", session.api_base_url.trim_end_matches('/'));
|
||||
format!("{}/api/llm/v1", session.api_base_url.trim_end_matches('/'));
|
||||
effective_llm.api_key.clear();
|
||||
// Only the platform catalog identifier reaches Codex. api-server
|
||||
// validates it and resolves the actual upstream model.
|
||||
@@ -2619,7 +2644,7 @@ impl CodexAppServerConnection {
|
||||
..
|
||||
} => (
|
||||
Some((
|
||||
format!("{}/api/llm", api_base_url.trim_end_matches('/')),
|
||||
format!("{}/api/llm/v1", api_base_url.trim_end_matches('/')),
|
||||
access_token.clone(),
|
||||
)),
|
||||
true,
|
||||
|
||||
@@ -4294,6 +4294,20 @@ pub(crate) fn check_direct_turn_preconditions(
|
||||
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
|
||||
Ok(())
|
||||
}
|
||||
/// Direct 回合在 `direct_execution::begin` 之后的分段 trace。
|
||||
///
|
||||
/// 起因:2026-10-02 连续四轮回合都停在 `begin` 之后——账本 `phase=working`、
|
||||
/// `nextSequence=0`、`active={}`、没有任何错误 sidecar、stderr 无 panic、连
|
||||
/// `agent.direct_codex.*` 都没有。窗口就夹在 `begin` 与执行器调用之间,需要知道
|
||||
/// 具体停在哪一步。测试态不写,避免污染本机 AppData。
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn direct_turn_trace(stage: &str) {
|
||||
app_log!("agent.direct_turn.trace stage={stage}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn direct_turn_trace(_stage: &str) {}
|
||||
|
||||
pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
@@ -4401,6 +4415,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
})?;
|
||||
let execution_session = execution_guard.session();
|
||||
execution_session.set_analytics_capture(capture.clone());
|
||||
direct_turn_trace("session-ready");
|
||||
let started = execution_session
|
||||
.newly_accepted
|
||||
.then(std::time::Instant::now);
|
||||
@@ -4411,6 +4426,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
}
|
||||
}
|
||||
}
|
||||
direct_turn_trace("run-analytics-accepted");
|
||||
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
|
||||
let result: Result<String, DirectTurnError> = async {
|
||||
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
|
||||
@@ -4444,6 +4460,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
);
|
||||
}
|
||||
}
|
||||
direct_turn_trace("goal-analytics-accepted");
|
||||
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit("running", Some("preparing"));
|
||||
@@ -4460,6 +4477,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error)
|
||||
},
|
||||
)?;
|
||||
direct_turn_trace("system-prompt-ready");
|
||||
// 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前,
|
||||
// 避免被长度上限截断,也不阻断任何工具。
|
||||
let engine_contract = direct_engine_three_dimensional_contract(root, prompt);
|
||||
@@ -4471,6 +4489,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
None => base_system_prompt,
|
||||
};
|
||||
let reply = if let Some(emitter) = turn_emitter {
|
||||
direct_turn_trace("executor-dispatch");
|
||||
let client_turn_id = emitter.turn_id().to_string();
|
||||
let emitter = emitter.clone();
|
||||
let mut observer = |observation: DirectCodexTurnObservation| {
|
||||
@@ -7338,7 +7357,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(error
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
|
||||
assert!(error.contains("<redacted-url>"), "{error}");
|
||||
assert!(error.contains("<absolute-path>"), "{error}");
|
||||
assert!(!error.contains("authorization=Bearer secret"), "{error}");
|
||||
@@ -7382,7 +7401,7 @@ mod tests {
|
||||
|
||||
assert!(
|
||||
error.starts_with(
|
||||
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
|
||||
"direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary="
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
@@ -7421,7 +7440,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(
|
||||
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
|
||||
error.contains("stage=art-preparation code=runtime-unclassified retryable=false"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
|
||||
|
||||
@@ -668,9 +668,6 @@ fn bridge_account_asset_page(arguments: &Value) -> Result<(usize, usize), String
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
if offset > 500 {
|
||||
return Err("工具参数 offset 不能超过 500".to_string());
|
||||
}
|
||||
let limit = arguments
|
||||
.get("limit")
|
||||
.filter(|value| !value.is_null())
|
||||
@@ -1232,6 +1229,9 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
|
||||
}
|
||||
let (offset, limit) = bridge_account_asset_page(arguments)?;
|
||||
if offset > 500 {
|
||||
return Err("工具参数 offset 不能超过 500".to_string());
|
||||
}
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
let registered_ids = manifest
|
||||
.assets
|
||||
@@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
let scope_relative = scope
|
||||
.and_then(|scope| scope.project_relative)
|
||||
.filter(|path| !path.is_empty());
|
||||
let listed = list_local_project_files_at(root)?;
|
||||
let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/"));
|
||||
let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| {
|
||||
bridge_project_file_is_hidden_control_path(path)
|
||||
|| should_skip_project_snapshot_path(path)
|
||||
|| reject_sensitive_project_file_read(path).is_err()
|
||||
})?;
|
||||
let mut files = listed
|
||||
.files
|
||||
.into_iter()
|
||||
.filter(|file| file.kind == "file")
|
||||
.filter(|file| !bridge_project_file_is_hidden_control_path(&file.path))
|
||||
.filter(|file| !should_skip_project_snapshot_path(&file.path))
|
||||
.filter(|file| reject_sensitive_project_file_read(&file.path).is_ok())
|
||||
.filter(|file| {
|
||||
scope_relative.as_ref().is_none_or(|scope| {
|
||||
file.path == *scope
|
||||
|| scope_prefix
|
||||
.as_ref()
|
||||
.is_some_and(|prefix| file.path.starts_with(prefix))
|
||||
})
|
||||
})
|
||||
.filter(|file| {
|
||||
let (category, _) = bridge_project_file_class(&file.path);
|
||||
requested_kind == "all" || requested_kind == category
|
||||
@@ -1622,11 +1614,13 @@ async fn bridge_import_account_assets(
|
||||
}
|
||||
}
|
||||
if !local_paths.is_empty() {
|
||||
match crate::commands::import_local_project_assets_for_agent_with_write_permit(
|
||||
match crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
|
||||
&state.root,
|
||||
&local_paths,
|
||||
write_permit.as_ref(),
|
||||
) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
|
||||
json!({
|
||||
"id": asset.id,
|
||||
@@ -4193,6 +4187,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap();
|
||||
for path in [
|
||||
"assets/z.png",
|
||||
"assets/nested/b.png",
|
||||
"assets/a.png",
|
||||
"assets/notes.txt",
|
||||
"assets-other/wrong.png",
|
||||
"unrelated/wrong.png",
|
||||
".agent/hidden.png",
|
||||
"assets/.env",
|
||||
"assets/secret.key",
|
||||
"assets/.codex/hidden.png",
|
||||
"assets/node_modules/hidden.png",
|
||||
"assets/.git/hidden.png",
|
||||
] {
|
||||
let target = root.join(path);
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(target, b"test-only").unwrap();
|
||||
}
|
||||
let payload = |arguments: Value| {
|
||||
let result = bridge_list_project_files(root, &arguments);
|
||||
assert_eq!(result["isError"], false, "{result}");
|
||||
serde_json::from_str::<Value>(result["content"][0]["text"].as_str().unwrap()).unwrap()
|
||||
};
|
||||
let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 }));
|
||||
assert_eq!(first["total"], 3);
|
||||
assert_eq!(first["offset"], 0);
|
||||
assert_eq!(first["limit"], 2);
|
||||
assert_eq!(first["nextOffset"], 2);
|
||||
assert_eq!(first["files"][0]["path"], "assets/a.png");
|
||||
assert_eq!(first["files"][1]["path"], "assets/nested/b.png");
|
||||
let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 }));
|
||||
assert_eq!(last["total"], 3);
|
||||
assert_eq!(last["files"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(last["files"][0]["path"], "assets/z.png");
|
||||
assert!(last["nextOffset"].is_null());
|
||||
for path in ["assets/nested", "assets/nested/b.png"] {
|
||||
let page = payload(json!({ "path": path }));
|
||||
assert_eq!(page["total"], 1);
|
||||
assert_eq!(page["files"][0]["path"], "assets/nested/b.png");
|
||||
}
|
||||
assert_eq!(
|
||||
payload(json!({ "path": "assets", "query": "NESTED" }))["total"],
|
||||
1
|
||||
);
|
||||
assert_eq!(payload(json!({ "path": "assets" }))["total"], 4);
|
||||
assert_eq!(payload(json!({ "path": "missing" }))["total"], 0);
|
||||
let beyond = payload(json!({ "path": "assets", "offset": 20 }));
|
||||
assert!(beyond["files"].as_array().unwrap().is_empty());
|
||||
assert!(beyond["nextOffset"].is_null());
|
||||
for path in [
|
||||
".agent",
|
||||
"assets/.env",
|
||||
"assets/.codex",
|
||||
"assets/node_modules",
|
||||
] {
|
||||
assert_eq!(
|
||||
bridge_list_project_files(root, &json!({ "path": path }))["isError"],
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
|
||||
use crate::analytics::{
|
||||
@@ -4748,21 +4809,39 @@ mod tests {
|
||||
let revision_before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
session
|
||||
.interrupt("fixture cancellation before asset commit".into())
|
||||
.unwrap();
|
||||
assert!(
|
||||
crate::commands::import_local_project_assets_for_agent_with_write_permit(
|
||||
assert!(crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
|
||||
root,
|
||||
&["game/import-source.png".to_string()],
|
||||
Some(&permit),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit)
|
||||
.is_err()
|
||||
);
|
||||
),
|
||||
|| session
|
||||
.interrupt("fixture cancellation during asset lock wait".into())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
let commit_root = root.to_path_buf();
|
||||
let commit_bytes = bytes.clone();
|
||||
let commit_permit = permit.clone();
|
||||
assert!(crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::commands::commit_account_asset_for_write_permit_test(
|
||||
&commit_root,
|
||||
&commit_bytes,
|
||||
&commit_permit,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
},
|
||||
|| {}
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
fs::read(root.join(".agent/manifest.json")).unwrap(),
|
||||
manifest_before
|
||||
|
||||
@@ -443,7 +443,7 @@ fn direct_tools_mcp_specs_for_plugins(
|
||||
"properties": {
|
||||
"folderId": { "type": "string", "minLength": 1, "maxLength": 512 },
|
||||
"query": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"offset": { "type": "integer", "minimum": 0, "maximum": 500 },
|
||||
"offset": { "type": "integer", "minimum": 0 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -981,15 +981,6 @@ fn validate_account_asset_library_arguments(arguments: &Value) -> Result<(), Str
|
||||
{
|
||||
return Err("工具参数 offset 必须是非负整数".to_string());
|
||||
}
|
||||
if let Some(offset) = arguments
|
||||
.get("offset")
|
||||
.filter(|value| !value.is_null())
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
if offset > 500 {
|
||||
return Err("工具参数 offset 不能超过 500".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(limit) = arguments.get("limit").filter(|value| !value.is_null()) {
|
||||
let limit = limit
|
||||
.as_u64()
|
||||
@@ -3283,6 +3274,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() {
|
||||
let specs = direct_tools_mcp_specs_for(false, false);
|
||||
let account_tool = specs["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "agc_list_account_assets")
|
||||
.unwrap();
|
||||
let validator = jsonschema::validator_for(&account_tool["inputSchema"]).unwrap();
|
||||
assert!(validator.is_valid(&json!({"offset": 501, "limit": 1})));
|
||||
assert!(!validator.is_valid(&json!({"offset": 501, "limit": 101})));
|
||||
assert!(validate_registered_assets_arguments(&json!({
|
||||
"kind": "character-animation",
|
||||
"includeSequenceFrames": true,
|
||||
@@ -3320,7 +3321,10 @@ mod tests {
|
||||
"limit": 100
|
||||
}))
|
||||
.is_ok());
|
||||
assert!(validate_account_asset_library_arguments(&json!({ "offset": 501 })).is_err());
|
||||
assert!(validate_account_asset_library_arguments(&json!({ "offset": 501 })).is_ok());
|
||||
assert!(validate_account_asset_library_arguments(&json!({ "offset": -1 })).is_err());
|
||||
assert!(validate_account_asset_library_arguments(&json!({ "offset": 1.5 })).is_err());
|
||||
assert!(validate_account_asset_library_arguments(&json!({ "limit": 101 })).is_err());
|
||||
assert!(validate_account_asset_library_arguments(&json!({ "unknown": true })).is_err());
|
||||
assert!(validate_account_asset_import_arguments(&json!({
|
||||
"assetIds": ["asset-1"],
|
||||
@@ -3903,6 +3907,233 @@ mod tests {
|
||||
.unwrap_or_else(|error| panic!("工具返回不是 JSON:{error};{result}"))
|
||||
}
|
||||
|
||||
/// 大库仍通过真实工具桥分页和按 ID 导入,平台私有字段不进入工具返回。
|
||||
#[tokio::test]
|
||||
async fn account_asset_large_library_tool_chain_pages_filters_and_imports() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("direct-tools-account-large-library-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "account-large-library", "账户大库工具链")
|
||||
.expect("init project");
|
||||
let mut assets = (0..503)
|
||||
.map(|index| {
|
||||
json!({
|
||||
"assetId": format!("asset-{index:04}"),
|
||||
"folderId": if index < 501 { "folder-main" } else { "folder-target" },
|
||||
"label": format!("素材-{index:04}"),
|
||||
"objectKey": format!("private/account-{index:04}.png"),
|
||||
"imageSrc": format!("/private/account-{index:04}.png"),
|
||||
"assetObjectId": format!("private-object-{index:04}"),
|
||||
"assetKind": "image",
|
||||
"width": 2,
|
||||
"height": 2
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assets.push(json!({
|
||||
"assetId": "unsupported-video",
|
||||
"folderId": "folder-main",
|
||||
"label": "不支持的视频",
|
||||
"objectKey": "private/video.mp4",
|
||||
"assetKind": "video"
|
||||
}));
|
||||
let library = json!({"data": {"library": {
|
||||
"folders": [
|
||||
{"folderId": "folder-main", "label": "图标目录"},
|
||||
{"folderId": "folder-target", "label": "背景目录"}
|
||||
],
|
||||
"assets": assets
|
||||
}}});
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind account fixture");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
|
||||
let media_url = format!("{base_url}/account-image.png");
|
||||
let png = tool_chain_png();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/editor/assets/library",
|
||||
axum::routing::get(move |headers: HeaderMap| {
|
||||
let library = library.clone();
|
||||
async move {
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Bearer tool-chain-token")
|
||||
);
|
||||
Json(library)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/read-url",
|
||||
axum::routing::get(
|
||||
move |axum::extract::Query(query): axum::extract::Query<
|
||||
std::collections::HashMap<String, String>,
|
||||
>| {
|
||||
let media_url = media_url.clone();
|
||||
async move {
|
||||
assert_eq!(
|
||||
query.get("objectKey").map(String::as_str),
|
||||
Some("private/account-0502.png")
|
||||
);
|
||||
Json(json!({"data": {"read": {"signedUrl": media_url}}}))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/account-image.png",
|
||||
axum::routing::get(move || {
|
||||
let png = png.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "image/png")], png) }
|
||||
}),
|
||||
);
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
let _session = crate::platform_session::install_test_platform_session(
|
||||
"account-large-library-owner",
|
||||
TOOL_CHAIN_ACCESS_TOKEN,
|
||||
&base_url,
|
||||
);
|
||||
let (bridge, _turn_arm, _execution) = tool_chain_start(root).await;
|
||||
let _turn = bridge.begin_user_turn().expect("begin client turn");
|
||||
|
||||
for (arguments, expected_total, expected_id, expected_next) in [
|
||||
(
|
||||
json!({"offset": 501, "limit": 1}),
|
||||
503,
|
||||
"asset-0501",
|
||||
json!(502),
|
||||
),
|
||||
(
|
||||
json!({"offset": 502, "limit": 1}),
|
||||
503,
|
||||
"asset-0502",
|
||||
Value::Null,
|
||||
),
|
||||
(
|
||||
json!({"folderId": "folder-target", "query": "素材-0502", "limit": 1}),
|
||||
1,
|
||||
"asset-0502",
|
||||
Value::Null,
|
||||
),
|
||||
(
|
||||
json!({"query": "背景目录", "limit": 1}),
|
||||
2,
|
||||
"asset-0501",
|
||||
json!(1),
|
||||
),
|
||||
] {
|
||||
let result = tool_chain_call(&bridge, root, "agc_list_account_assets", arguments).await;
|
||||
assert_eq!(result["result"]["isError"], false, "{result}");
|
||||
assert_eq!(result["result"]["content"].as_array().unwrap().len(), 1);
|
||||
let payload = tool_chain_payload(&result);
|
||||
assert_eq!(payload["total"], expected_total, "{payload}");
|
||||
assert_eq!(payload["limit"], 1);
|
||||
assert_eq!(payload["assets"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(payload["assets"][0]["assetId"], expected_id);
|
||||
assert_eq!(payload["nextOffset"], expected_next);
|
||||
let text = result.to_string();
|
||||
for private in [
|
||||
"private/account-",
|
||||
"private-object-",
|
||||
TOOL_CHAIN_ACCESS_TOKEN,
|
||||
&base_url,
|
||||
] {
|
||||
assert!(!text.contains(private), "私有字段进入工具返回:{private}");
|
||||
}
|
||||
}
|
||||
for offset in [503, usize::MAX] {
|
||||
let result = tool_chain_call(
|
||||
&bridge,
|
||||
root,
|
||||
"agc_list_account_assets",
|
||||
json!({"offset": offset}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result["result"]["isError"], false, "{result}");
|
||||
let payload = tool_chain_payload(&result);
|
||||
assert_eq!(payload["total"], 503);
|
||||
assert_eq!(payload["offset"], json!(offset));
|
||||
assert_eq!(payload["limit"], 100);
|
||||
assert_eq!(payload["assets"], json!([]));
|
||||
assert_eq!(payload["nextOffset"], Value::Null);
|
||||
}
|
||||
let runtime = crate::agent::observe_agent_runtime_account_asset_library(
|
||||
root,
|
||||
&json!({"offset": 501, "limit": 1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(runtime.status, "ok", "{}", runtime.summary);
|
||||
let runtime_payload: Value =
|
||||
serde_json::from_str(runtime.detail.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(runtime_payload["assets"][0]["assetId"], "asset-0501");
|
||||
assert_eq!(runtime_payload["nextOffset"], 502);
|
||||
|
||||
let snapshot = crate::commands::read_editor_asset_library()
|
||||
.await
|
||||
.expect("read large account library for UI");
|
||||
assert_eq!(snapshot.assets.len(), 503);
|
||||
assert!(snapshot
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.asset_id == "asset-0502" && asset.preview_available));
|
||||
let preview = crate::commands::read_editor_asset_preview("asset-0502".to_string())
|
||||
.await
|
||||
.expect("preview image from large account library");
|
||||
assert!(preview.preview_url.starts_with("data:image/png;base64,"));
|
||||
|
||||
let imported = tool_chain_call(
|
||||
&bridge,
|
||||
root,
|
||||
"agc_import_account_assets",
|
||||
json!({"assetIds": ["asset-0502"]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(imported["result"]["isError"], false, "{imported}");
|
||||
let imported_payload = tool_chain_payload(&imported);
|
||||
assert_eq!(imported_payload["status"], "completed");
|
||||
assert_eq!(imported_payload["revisionAdvanceCount"], 1);
|
||||
let imported_asset = &imported_payload["imported"][0];
|
||||
let local_path = imported_asset["localPath"].as_str().expect("imported path");
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(local_path)).unwrap(),
|
||||
tool_chain_png()
|
||||
);
|
||||
let manifest_before = std::fs::read(root.join(".agent/manifest.json")).unwrap();
|
||||
|
||||
let repeated = tool_chain_call(
|
||||
&bridge,
|
||||
root,
|
||||
"agc_import_account_assets",
|
||||
json!({"assetIds": ["asset-0502"]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(repeated["result"]["isError"], false, "{repeated}");
|
||||
let repeated_payload = tool_chain_payload(&repeated);
|
||||
assert_eq!(repeated_payload["imported"][0]["id"], imported_asset["id"]);
|
||||
assert_eq!(repeated_payload["revisionAdvanceCount"], 0);
|
||||
for id in ["missing-asset", "unsupported-video"] {
|
||||
let rejected = tool_chain_call(
|
||||
&bridge,
|
||||
root,
|
||||
"agc_import_account_assets",
|
||||
json!({"assetIds": [id]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rejected["result"]["isError"], true, "{rejected}");
|
||||
let rejected_payload = tool_chain_payload(&rejected);
|
||||
assert_eq!(rejected_payload["status"], "failed");
|
||||
assert_eq!(rejected_payload["imported"], json!([]));
|
||||
assert_eq!(rejected_payload["revisionAdvanceCount"], 0);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(".agent/manifest.json")).unwrap(),
|
||||
manifest_before
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// P1 门禁:工具 schema 的 per-kind 上限、MCP 校验、工具桥校验与客户端权威口径必须是同一个数字。
|
||||
#[test]
|
||||
fn tool_prompt_limits_agree_with_the_client_authority() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user