合并 origin/master 到 feat/typed-tool-error

- 取 master 退役 DirectProject 工具调用与回合流账本(#560):删除 direct_tool_calls / direct_turn_stream,direct_events 的回合更新收敛为状态与活动,不再广播 game-creator-direct-turn-update
- 取 master 修复 AGC 文件列举被无关临时锁删除打断(#552)与素材导入 / 资源提交项目锁争用(#581)
- 保留本分支 typed 工具错误口径:逐工具错误枚举、派发边界携带原始 error、工具返回不再软失败
- 保留本分支退役 AGC 独立 Agent Runtime / CLI 执行面的删除,继续删掉 master 侧 runtime_actions 等测试与入口
- 补齐 master 新增的 with_conversation_write_contention 测试助手,项目写锁调用统一为 acquire_project_write_lock_with_wait
- 账户素材分页 offset 上限 500 的 schema、预检与行为对齐,越界 offset 返回 typed 错误并适配相关用例
- 错误事件结构体 message 保留调用方原文,脱敏只发生在落盘与应用日志边界
This commit is contained in:
2026-10-02 16:18:50 +08:00
213 changed files with 18130 additions and 4030 deletions
@@ -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(
+62
View File
@@ -38,6 +38,13 @@ import type {
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminGameReviewDetailResponse,
AdminGameReviewGamesQuery,
AdminGameReviewGamesResponse,
AdminGameReviewModerationRequest,
AdminGameReviewModerationResponse,
AdminGameReviewsQuery,
AdminGameReviewsResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
@@ -1243,6 +1250,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
);
}
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: { limit?: number } = {},
+90
View File
@@ -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 {
@@ -1186,6 +1192,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;
+11 -1
View File
@@ -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}
/>
+1
View File
@@ -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(
+2
View File
@@ -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} 启用`}
@@ -117,6 +117,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();
@@ -28,6 +28,7 @@ import { handlePageError } from './pageUtils';
interface AdminGameManagementPageProps {
token: string;
canManageReviews?: boolean;
onUnauthorized: (message?: string) => void;
}
@@ -86,6 +87,7 @@ function createGameActionIdempotencyKey(
export function AdminGameManagementPage({
token,
canManageReviews = false,
onUnauthorized,
}: AdminGameManagementPageProps) {
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
@@ -327,6 +329,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
+27
View File
@@ -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:
@@ -140,6 +140,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',
@@ -410,6 +411,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
'invokeClientAuth',
'invokeDiagnostic',
]);
@@ -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, &center.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"
);
}
}
@@ -27,11 +27,9 @@ mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_error;
mod direct_turn_failure;
mod direct_turn_stream;
mod direct_validation;
mod runtime_error;
mod runtime_models;
@@ -71,11 +69,9 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_error::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig;
pub(crate) use art_manifest::*;
pub(crate) use runtime_error::*;
@@ -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,32 +823,22 @@ 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());
parse_direct_stream_result(
stream.join("\n").as_bytes(),
client_turn_id.unwrap_or("claude-code-turn"),
observer,
)
direct_turn_trace("claude-parse-start");
parse_direct_stream_result(stream.join("\n").as_bytes(), observer)
}
fn parse_direct_stream_result(
stdout: &[u8],
turn_id: &str,
mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
) -> Result<String, String> {
let text = std::str::from_utf8(stdout)
.map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?;
let mut result = None;
let mut tool_calls = std::collections::HashMap::<String, DirectToolCall>::new();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
let event: serde_json::Value = serde_json::from_str(line)
.map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?;
let event_type = event.get("type").and_then(serde_json::Value::as_str);
if event_type == Some("assistant") {
let id = event
.get("uuid")
.and_then(serde_json::Value::as_str)
.unwrap_or("claude-code-assistant")
.to_string();
let content = event
.pointer("/message/content")
.and_then(serde_json::Value::as_array);
@@ -754,83 +857,16 @@ fn parse_direct_stream_result(
.collect::<String>();
if !visible.is_empty() {
if let Some(observer) = observer.as_deref_mut() {
observer(DirectCodexTurnObservation::AgentMessageSegment {
item_id: id,
accumulated_text: visible,
completed: true,
});
}
}
for part in content.iter().filter(|part| {
part.get("type").and_then(serde_json::Value::as_str) == Some("tool_use")
}) {
let Some(call_id) = part.get("id").and_then(serde_json::Value::as_str) else {
continue;
};
let name = part
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("mcp_tool")
.to_string();
let arguments = part
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
let now = direct_tool_call_now_ms();
let call = DirectToolCall {
schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(),
id: call_id.to_string(),
turn_id: turn_id.to_string(),
kind: "mcp_tool".to_string(),
title: "调用工具".to_string(),
summary: name,
status: "running".to_string(),
detail: DirectToolCallDetail {
command: serde_json::to_string(&arguments).ok(),
output: None,
changes: Vec::new(),
},
started_at: now,
updated_at: now,
};
if let Some(observer) = observer.as_deref_mut() {
observer(DirectCodexTurnObservation::ToolCall(call.clone()));
}
tool_calls.insert(call_id.to_string(), call);
}
}
} else if event_type == Some("user") {
if let Some(content) = event
.pointer("/message/content")
.and_then(serde_json::Value::as_array)
{
for part in content.iter().filter(|part| {
part.get("type").and_then(serde_json::Value::as_str) == Some("tool_result")
}) {
let Some(call_id) = part.get("tool_use_id").and_then(serde_json::Value::as_str)
else {
continue;
};
let Some(mut call) = tool_calls.remove(call_id) else {
continue;
};
call.status = "completed".to_string();
call.updated_at = direct_tool_call_now_ms();
call.detail.output = part
.get("content")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
if part.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
call.status = "failed".to_string();
}
if let Some(observer) = observer.as_deref_mut() {
observer(DirectCodexTurnObservation::ToolCall(call));
observer(DirectCodexTurnObservation::AgentMessageSegment(visible));
}
}
}
} 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")
@@ -885,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();
@@ -894,14 +940,24 @@ mod tests {
{"type":"result","is_error":false,"result":"最终回复"}
"#
.as_bytes(),
"turn-1",
Some(&mut |event| observed.push(event)),
)
.expect("stream result");
assert_eq!(text, "最终回复");
assert!(matches!(
observed.as_slice(),
[DirectCodexTurnObservation::AgentMessageSegment { .. }]
[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"));
}
}
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,7 @@ mod execution;
mod model_catalog;
pub(crate) use direct_project_identity::thread_id_for_project;
use direct_project_identity::*;
use execution::ExecutionAdapter;
use execution::{ExecutionAdapter, ResponseDeliveryFailure};
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
@@ -642,21 +642,12 @@ enum CodexTurnEvent {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DirectCodexTurnObservation {
AccumulatedText(String),
/// 一个 assistant 文本段的当前累计全文。
///
/// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**,
/// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。
AgentMessageSegment {
item_id: String,
accumulated_text: String,
completed: bool,
},
/// 一段可见的 assistant 正文(同一 assistant item 的当前累计全文)。
AgentMessageSegment(String),
IntermediateText(String),
/// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。
Reasoning(String),
Activity(&'static str),
/// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。
ToolCall(crate::DirectToolCall),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -809,7 +800,7 @@ fn direct_thread_event_item(
root: &std::path::Path,
item: &serde_json::Value,
) -> Option<ThreadItem> {
thread_item_from_value(root, item, direct_tool_call_now_ms())
thread_item_from_value(root, item, direct_now_ms())
}
/// 运行态条目投影:Codex 回显的用户消息整条跳过。
@@ -1980,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,
@@ -2136,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!(
@@ -2160,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);
}
@@ -2360,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.
@@ -2628,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,
@@ -3325,9 +3341,9 @@ impl CodexAppServerConnection {
) -> Result<platform_llm::LlmRunResponse, DirectTurnRunFailure> {
let _turn_guard = self.inner.turn_gate.lock().await;
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
// 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径),
// 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。
let direct_tool_call_turn_id: Option<String> = direct_client_turn_id
// 回合身份用 AGC 客户端回合 id(与实时事件、落盘条目同一口径),
// 不用 Codex app-server 自己的 turnId。
let direct_turn_id: Option<String> = direct_client_turn_id
.map(str::trim)
.filter(|turn_id| !turn_id.is_empty())
.map(str::to_string);
@@ -3506,7 +3522,7 @@ impl CodexAppServerConnection {
}
let context = ProjectModelUsageContext {
root: history_root.to_path_buf(),
client_turn_id: direct_tool_call_turn_id.clone(),
client_turn_id: direct_turn_id.clone(),
thread_id: Some(thread_id.clone()),
requested_model: model.to_string(),
};
@@ -3527,7 +3543,7 @@ impl CodexAppServerConnection {
let turn_start_cancellation =
Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id));
// Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。
let _active_turn_guard = direct_tool_call_turn_id
let _active_turn_guard = direct_turn_id
.as_deref()
.filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.map(|turn_id| {
@@ -3611,7 +3627,7 @@ impl CodexAppServerConnection {
// `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能
// 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与
// `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
let direct_turn_started_at_ms = direct_now_ms();
let mut receiver = self.register_turn(&turn_id).await;
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
let mut guard = CodexTurnGuard {
@@ -3711,17 +3727,14 @@ impl CodexAppServerConnection {
observer(DirectCodexTurnObservation::AccumulatedText(
streamed_text.clone(),
));
// 同一个 assistant item 的当前累计全文:回合流按 item 分段,
// 段内只追加、段间才换行,不能拿"整轮累计"当一段。
// 同一 assistant item 的当前累计全文,不能拿"整轮累计"当一段。
let segment_text = direct_project_history
.accumulated_text_for(&item_id)
.unwrap_or_else(|| delta.clone());
if !segment_text.trim().is_empty() {
observer(DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.clone(),
accumulated_text: segment_text,
completed: false,
});
observer(DirectCodexTurnObservation::AgentMessageSegment(
segment_text,
));
}
}
if let Some(callback) = on_agent_message_delta.as_deref_mut() {
@@ -3780,10 +3793,7 @@ impl CodexAppServerConnection {
// 通知的钟就是该阶段唯一可证明的时间。
append_thread_event(
&direct_thread_id,
ThreadEvent::item_completed(
entry_item,
direct_tool_call_now_ms(),
),
ThreadEvent::item_completed(entry_item, direct_now_ms()),
);
}
}
@@ -3841,43 +3851,22 @@ impl CodexAppServerConnection {
completed,
&params,
);
// 工具调用卡片:item/started 与 item/completed 各采一次,
// 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id /
// 非工具类 item)就静默跳过,不影响这一轮的其它投影。
if let Some(turn_id) = direct_tool_call_turn_id.as_deref() {
if let Some(tool_call) = direct_tool_call_from_item(
history_root,
item,
turn_id,
completed,
direct_tool_call_now_ms(),
) {
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::ToolCall(
tool_call,
));
}
}
}
}
if item_type == "agentMessage" {
// 某些 app-server 实现会在工具开始后停止发送 agentMessage delta,
// 但会在 item/completed 携带完整文本。把这份最终快照补进回合流,
// 让流中的文本段不会停在工具前的短前缀。
// 但会在 item/completed 携带完整文本;把这份最终快照也交给观察者,
// 运行态条目就不会停在工具前的短前缀。
if completed {
if let (Some(item_id), Some(text)) = (
item.get("id").and_then(serde_json::Value::as_str),
item.get("text")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty()),
) {
if let Some(text) = item
.get("text")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
{
if let Some(observer) = direct_observer.as_deref_mut() {
observer(
DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.to_string(),
accumulated_text: text.to_string(),
completed: true,
},
DirectCodexTurnObservation::AgentMessageSegment(
text.to_string(),
),
);
}
}
@@ -3919,7 +3908,7 @@ impl CodexAppServerConnection {
&params,
item,
false,
direct_tool_call_now_ms(),
direct_now_ms(),
),
),
);
@@ -3943,15 +3932,10 @@ impl CodexAppServerConnection {
.filter(|text| !text.trim().is_empty())
{
final_text = Some(text.to_string());
if let (Some(item_id), Some(observer)) = (
item.get("id").and_then(serde_json::Value::as_str),
direct_observer.as_deref_mut(),
) {
observer(DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.to_string(),
accumulated_text: text.to_string(),
completed: true,
});
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::AgentMessageSegment(
text.to_string(),
));
}
}
}
@@ -3968,7 +3952,7 @@ impl CodexAppServerConnection {
thread_turn_completed_at_ms(
turn,
Some(direct_turn_started_at_ms),
direct_tool_call_now_ms(),
direct_now_ms(),
),
));
}
@@ -4138,12 +4122,12 @@ impl CodexAppServerConnection {
.unwrap_or_else(|| fallback_status.to_string());
// 有执行许可时,起止时间包含实际宿主收尾;上游模型完成不能提前结束 UI。
let completed_at = if approval_adapter.is_some() {
direct_tool_call_now_ms()
direct_now_ms()
} else {
model_terminal
.as_ref()
.map(|(_, at)| *at)
.unwrap_or_else(direct_tool_call_now_ms)
.unwrap_or_else(direct_now_ms)
};
// 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的
// `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure`
@@ -4865,7 +4849,7 @@ async fn read_game_creator_codex_app_server_stdout(
};
if !sent {
if let Some(adapter) = adapter {
adapter.response_write_failed();
adapter.response_delivery_failed(ResponseDeliveryFailure::Write);
}
}
});
@@ -4897,7 +4881,9 @@ async fn read_game_creator_codex_app_server_stdout(
let adapter = inner.execution.lock().ok().and_then(|slot| slot.clone());
if let Some(adapter) = adapter {
if !adapter.bind_turn(turn_id) {
adapter.response_write_failed();
adapter.response_delivery_failed(
ResponseDeliveryFailure::TurnBindMismatch,
);
}
}
}
@@ -2,10 +2,8 @@
//! 从已退役的 runtime_driver 中抽出,仅保留直连路径与外部编辑器桥仍在使用的事件接线。
use crate::agent::thread_manager::update_active_turn;
use crate::{DirectToolCall, DirectTurnStreamItem};
use crate::{GameCreatorAgentProgressEvent, GameCreatorDirectTurnUpdateEvent,
GameCreatorManifestInvalidatedEvent, GameCreatorManifestInvalidationEventSink,
GameCreatorManifestInvalidationRelayEnvelope};
use crate::{GameCreatorAgentProgressEvent, GameCreatorManifestInvalidatedEvent,
GameCreatorManifestInvalidationEventSink, GameCreatorManifestInvalidationRelayEnvelope};
use tauri::Emitter;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -86,7 +84,6 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
#[derive(Clone)]
pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
project_path: String,
/// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。
thread_id: String,
turn_id: String,
@@ -96,74 +93,14 @@ pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
impl DirectGameCreatorTurnUpdateEmitter {
pub(crate) fn new(root: &Path, turn_id: String) -> Self {
Self {
project_path: root.to_string_lossy().into_owned(),
thread_id: crate::agent::thread_id_for_project(root),
turn_id,
sequence: Arc::new(AtomicU64::new(0)),
}
}
pub(crate) fn emit(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<DirectToolCall>>,
) {
self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None);
}
/// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。
pub(crate) fn emit_with_reasoning(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<DirectToolCall>>,
reasoning_text: Option<String>,
) {
self.emit_full(
status,
activity,
accumulated_text,
tool_calls,
reasoning_text,
Vec::new(),
);
}
/// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。
///
/// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据,
/// 不能在前端各算一套顺序。
pub(crate) fn emit_with_stream_items(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<DirectToolCall>>,
stream_items: Vec<DirectTurnStreamItem>,
) {
self.emit_full(
status,
activity,
accumulated_text,
tool_calls,
None,
stream_items,
);
}
#[allow(clippy::too_many_arguments)]
fn emit_full(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<DirectToolCall>>,
reasoning_text: Option<String>,
stream_items: Vec<DirectTurnStreamItem>,
) {
/// 回填这一轮逻辑回合的进度(状态 / 活动 / 序号);首页「运行中的项目」快照读它。
pub(crate) fn emit(&self, status: &'static str, activity: Option<&'static str>) {
let status_is_allowed = matches!(
status,
"accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed"
@@ -180,6 +117,8 @@ impl DirectGameCreatorTurnUpdateEmitter {
| "controlled-tool"
| "web-search"
| "context-compaction"
| "host-review"
| "error-feedback"
| "response-finalization"
| "none"
)
@@ -202,24 +141,6 @@ impl DirectGameCreatorTurnUpdateEmitter {
sequence,
updated_at,
);
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let _ = app.emit(
"game-creator-direct-turn-update",
GameCreatorDirectTurnUpdateEvent {
project_path: self.project_path.clone(),
turn_id: self.turn_id.clone(),
sequence,
status: status.to_string(),
activity: activity.map(str::to_string),
accumulated_text,
tool_calls,
reasoning_text,
stream_items: (!stream_items.is_empty()).then_some(stream_items),
updated_at,
},
);
}
pub(crate) fn turn_id(&self) -> &str {

Some files were not shown because too many files have changed in this diff Show More