修复资源画布PR的CI回归(#410)
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled

修复活动回合空快照引用及停用后晚到请求覆盖问题
对齐共享卡片角标与窗口发布次数回归断言
同步原生HTTP权限检查与图集显式切片测试契约
整理菜单组件导入顺序并记录本地验证范围
This commit is contained in:
2026-09-17 20:17:26 +08:00
parent 959beebf63
commit c329c438d9
15 changed files with 123 additions and 29 deletions
@@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
"tool": "canvas.asset_generate",
"reason": "生成可用于首版原型的主角素材",
"input": {
"prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏",
"prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏",
"outputPath": "assets/art-spritesheet.png",
"aspectRatio": "1:1",
"imageSize": "1K",
"assetKind": "art-spritesheet",
"assetLabel": "游戏首版核心美术素材",
"replaceExisting": false
"replaceExisting": false,
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"sliceCount": null
}
}
],
@@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
start_game_creator_agent_background_task_at(
&root,
"art-asset-plan",
"为月光厨房生成首版主角素材",
"为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布",
"art-generate-run",
)
.expect("start background task");
@@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
let generation_idempotency_key =
request_header(generation_request, "idempotency-key").expect("generation idempotency key");
assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok());
let generation_body: Value = serde_json::from_str(
generation_request
.split_once("\r\n\r\n")
.expect("generation request body")
.1,
)
.expect("generation request json");
assert_eq!(generation_body["sliceMode"], "grid");
assert_eq!(generation_body["gridX"], 2);
assert_eq!(generation_body["gridY"], 2);
assert!(generation_body["sliceCount"].is_null());
assert!(generation_request.contains(r#""source":"ai-game-creator-client""#));
assert_eq!(
canvas_requests
@@ -6916,7 +6916,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"imageSize",
"assetKind",
"assetLabel",
"replaceExisting"
"replaceExisting",
"sliceMode",
"gridX",
"gridY",
"sliceCount"
])
);
assert_eq!(
@@ -6927,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"],
serde_json::json!(["0.5K", "1K", "2K", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"],
serde_json::json!(["connected-components", "grid", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"],
serde_json::json!([
@@ -45,7 +45,8 @@ export function useDirectActiveTurns({
* 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
*/
const lastSnapshotSignatureRef = useRef<string>('');
const lastSnapshotSignatureRef = useRef<string>('[]');
const requestGenerationRef = useRef(0);
const retryTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -60,13 +61,16 @@ export function useDirectActiveTurns({
}, []);
const refreshActiveTurns = useCallback(async () => {
if (!invoke) {
if (!enabled || !invoke) {
return;
}
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
if (inFlightRef.current) {
return inFlightRef.current;
}
const generation = requestGenerationRef.current;
const isCurrent = () =>
mountedRef.current && generation === requestGenerationRef.current;
const request = (async () => {
for (
let attempt = 1;
@@ -77,7 +81,7 @@ export function useDirectActiveTurns({
const turns = await invoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
if (!mountedRef.current) {
if (!isCurrent()) {
return;
}
const nextTurns = Array.isArray(turns) ? turns : [];
@@ -90,6 +94,7 @@ export function useDirectActiveTurns({
inFlightRef.current = null;
return;
} catch {
if (!isCurrent()) return;
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
await new Promise<void>((resolve) => {
retryTimerRef.current = window.setTimeout(() => {
@@ -97,22 +102,23 @@ export function useDirectActiveTurns({
resolve();
}, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt);
});
if (!isCurrent()) return;
}
}
}
// 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。
if (mountedRef.current) {
if (isCurrent()) {
setSnapshotReadFailed(true);
inFlightRef.current = null;
}
inFlightRef.current = null;
})();
inFlightRef.current = request;
return request;
}, [invoke]);
}, [enabled, invoke]);
useEffect(() => {
if (!enabled || !invoke) {
lastSnapshotSignatureRef.current = '';
lastSnapshotSignatureRef.current = '[]';
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
setActiveTurns((current) => (current.length === 0 ? current : []));
setSnapshotReadFailed((current) => (current ? false : current));
@@ -123,7 +129,12 @@ export function useDirectActiveTurns({
() => void refreshActiveTurns(),
Math.max(1_000, pollIntervalMs),
);
return () => window.clearInterval(timer);
return () => {
window.clearInterval(timer);
// 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。
requestGenerationRef.current += 1;
inFlightRef.current = null;
};
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
@@ -14,8 +14,8 @@ import {
CanvasChromeButton,
SelectionOverlay,
} from '@genarrative/image-canvas-react';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { CanvasCardCornerActions } from '@genarrative/shared/components';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
AtSign,
Crosshair,
@@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => {
{ initialProps: { enabled: true } },
);
await waitFor(() => {
expect(result.current.activeTurns).toEqual([]);
});
const emptySnapshot = result.current.activeTurns;
await act(async () => {
await result.current.refreshActiveTurns();
});
expect(result.current.activeTurns).toBe(emptySnapshot);
rerender({ enabled: false });
expect(result.current.activeTurns).toBe(emptySnapshot);
});
@@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => {
clearTimeoutSpy.mockRestore();
}
});
it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
const invoke = vi.fn(
() =>
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
complete = resolve;
}),
);
const { result, rerender } = renderHook(
({ enabled }) =>
useDirectActiveTurns({ invoke: invoke as never, enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
const empty = result.current.activeTurns;
await act(async () => {
complete([ACTIVE_TURN]);
await result.current.refreshActiveTurns();
});
expect(result.current.activeTurns).toBe(empty);
expect(result.current.snapshotReadFailed).toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
});
it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
const invoke = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
completeOld = resolve;
}),
)
.mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]);
const { result, rerender } = renderHook(
({ enabled }) =>
useDirectActiveTurns({ invoke: invoke as never, enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
rerender({ enabled: true });
await waitFor(() =>
expect(result.current.activeTurns[0]?.runId).toBe('new-run'),
);
const current = result.current.activeTurns;
await act(async () => {
completeOld([ACTIVE_TURN]);
});
expect(result.current.activeTurns).toBe(current);
expect(invoke).toHaveBeenCalledTimes(2);
});
});
describe('ActiveProjectRunsPanel', () => {
@@ -84,8 +84,8 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
await act(async () => {
await Promise.resolve();
});
// 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。
expect(publications).toHaveLength(2);
// 无原生 invoke 时空快照引用不变,只发布一次。
expect(publications).toHaveLength(1);
expect(cleanups).toBe(0);
expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
1,
@@ -94,13 +94,13 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
const latestOpen = vi.fn(async () => undefined);
homeProjectOverride.openProject = latestOpen;
rendered.rerender(view('更改显示名'));
expect(publications).toHaveLength(2);
expect(publications).toHaveLength(1);
act(() => openProject('/tmp/window-latest-project'));
expect(latestOpen).toHaveBeenCalledWith(
'/tmp/window-latest-project',
'open',
);
expect(publications).toHaveLength(2);
expect(publications).toHaveLength(1);
expect(cleanups).toBe(0);
rendered.unmount();
expect(cleanups).toBe(1);
@@ -2,6 +2,12 @@
对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md)Issue #409,产品已确认方案 A。
## PR #410 CI 修复
以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。
本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。
1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。
2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。
3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。
@@ -21,6 +21,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡
工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。
活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。
## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」
- **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image``agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。
@@ -12,6 +12,7 @@
## 资源画布交互与工作台状态同步
- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。
- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。
@@ -1,5 +1,6 @@
import { Info } from 'lucide-react';
import type { CSSProperties, Ref } from 'react';
import { PlatformIconButton } from './PlatformIconButton';
/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */
@@ -7,8 +7,9 @@ import {
within,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OverflowActions } from './OverflowActions';
import { CanvasCardCornerActions } from './CanvasCardCornerActions';
import { OverflowActions } from './OverflowActions';
afterEach(cleanup);
describe('操作收纳与卡片角标', () => {
@@ -3,12 +3,12 @@ import {
cloneElement,
Fragment,
isValidElement,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
-1
View File
@@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() {
JSON.stringify([
{ url: 'https://dev.genarrative.world/api/*' },
{ url: 'https://www.genarrative.world/api/*' },
{ url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' },
{ url: 'https://*/api/*' },
{ url: 'http://localhost:*/*' },
{ url: 'http://127.0.0.1:*/*' },
@@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => {
if (!metadataCornerButton) {
throw new Error('metadata corner button should exist');
}
expect(metadataCornerButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
expect(metadataCornerButton.className).toContain('shared-canvas-card-info');
fireEvent.click(metadataCornerButton);
const metadataDialog = screen.getByRole('dialog', { name: '图片信息' });
@@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => {
const infoButton = screen.getByRole('button', {
name: '查看拼图素材图片信息',
});
expect(infoButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
expect(infoButton.className).toContain('shared-canvas-card-info');
fireEvent.click(infoButton);
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });