明确泥点不足的游戏生成中断原因
统一余额不足分类与稳定原因码 阻止泥点不足进入重试和对账状态 在游戏聊天与阶段记录展示固定安全说明 补齐 API、Runtime 与前端回归测试 同步技术方案与项目决策记录
This commit is contained in:
@@ -55,9 +55,12 @@ pub(crate) use draft_validation::{
|
||||
pub(crate) use draft_writer::write_local_game_draft_at;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use loop_orchestration::{
|
||||
emit_agent_progress, game_creator_agent_llm_error_public_summary,
|
||||
request_game_creator_llm_text, request_generator_game_draft_with_client,
|
||||
request_planner_spec_with_client, run_game_creator_agent_loop_at, AgentProgressEmitter,
|
||||
emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient,
|
||||
game_creator_agent_llm_error_public_summary, game_creator_mud_points_insufficient_message,
|
||||
game_creator_runtime_error_is_mud_points_insufficient, request_game_creator_llm_text,
|
||||
request_generator_game_draft_with_client, request_planner_spec_with_client,
|
||||
run_game_creator_agent_loop_at, AgentProgressEmitter,
|
||||
GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use pass_artifacts::{
|
||||
|
||||
@@ -511,9 +511,34 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_llm_client(
|
||||
build_game_creator_llm_client_without_redirects_from_llm_config(&single_attempt, config_path)
|
||||
}
|
||||
|
||||
pub(crate) const GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND: &str =
|
||||
"kind=mud-points-insufficient";
|
||||
|
||||
pub(crate) fn game_creator_mud_points_insufficient_message(message: &str) -> bool {
|
||||
let message = message.trim();
|
||||
message == "泥点余额不足" || message.starts_with("可消费泥点不足:")
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_runtime_error_is_mud_points_insufficient(error: &str) -> bool {
|
||||
error.contains("泥点余额不足") || error.contains("可消费泥点不足:")
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_llm_error_is_mud_points_insufficient(
|
||||
error: &platform_llm::LlmError,
|
||||
) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
platform_llm::LlmError::Upstream { message, .. }
|
||||
if game_creator_mud_points_insufficient_message(message)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_llm_error_public_summary(
|
||||
error: &platform_llm::LlmError,
|
||||
) -> String {
|
||||
if game_creator_agent_llm_error_is_mud_points_insufficient(error) {
|
||||
return GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND.to_string();
|
||||
}
|
||||
let (kind, http_status) = match error {
|
||||
platform_llm::LlmError::Timeout { .. } => ("timeout".to_string(), None),
|
||||
platform_llm::LlmError::Connectivity { .. } => ("connectivity".to_string(), None),
|
||||
|
||||
+61
-13
@@ -181,16 +181,32 @@ fn game_chat_fast_path_has_visual_asset(root: &Path, task_id: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_canvas_generation_failed(runtime: &AgentRuntimeState) -> bool {
|
||||
runtime.observations.iter().rev().any(|observation| {
|
||||
[
|
||||
fn game_chat_fast_path_canvas_generation_error(
|
||||
runtime: &AgentRuntimeState,
|
||||
default_error: &str,
|
||||
) -> Option<String> {
|
||||
runtime.observations.iter().rev().find_map(|observation| {
|
||||
let failed = [
|
||||
"canvas.asset_generate:failed",
|
||||
"canvas.asset_generate:blocked",
|
||||
"canvas.asset_generate:rejected",
|
||||
"canvas.asset_generate:needs-reconciliation",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| observation.starts_with(prefix))
|
||||
.any(|prefix| observation.starts_with(prefix));
|
||||
if !failed {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
if observation.contains(GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND)
|
||||
|| game_creator_runtime_error_is_mud_points_insufficient(observation)
|
||||
{
|
||||
GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND
|
||||
} else {
|
||||
default_error
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,11 +495,11 @@ pub(crate) fn game_chat_fast_path_plan_at(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if game_chat_fast_path_canvas_generation_failed(runtime) {
|
||||
return Err(
|
||||
"game-chat 统一视觉规范图生成失败,拒绝跳过美术阶段或退回纯几何首版"
|
||||
.to_string(),
|
||||
);
|
||||
if let Some(error) = game_chat_fast_path_canvas_generation_error(
|
||||
runtime,
|
||||
"game-chat 统一视觉规范图生成失败,拒绝跳过美术阶段或退回纯几何首版",
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
let root_task = game_chat_fast_path_root_task(root, &budget)?;
|
||||
Ok(Some(game_chat_fast_path_canvas_asset_plan(
|
||||
@@ -516,10 +532,11 @@ pub(crate) fn game_chat_fast_path_plan_at(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if game_chat_fast_path_canvas_generation_failed(runtime) {
|
||||
return Err(
|
||||
"game-chat 透明核心美术图集生成失败,拒绝退回纯代码核心画面".to_string()
|
||||
);
|
||||
if let Some(error) = game_chat_fast_path_canvas_generation_error(
|
||||
runtime,
|
||||
"game-chat 透明核心美术图集生成失败,拒绝退回纯代码核心画面",
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
let root_task = game_chat_fast_path_root_task(root, &budget)?;
|
||||
Ok(Some(game_chat_fast_path_canvas_asset_plan(
|
||||
@@ -1373,6 +1390,37 @@ mod tests {
|
||||
assert!(!prompt.contains("可直接作为首版主要背景"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_generation_mud_point_failure_keeps_the_stable_reason() {
|
||||
let temporary = tempfile::tempdir().expect("temporary project");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-mud-points", "mud points")
|
||||
.expect("initialize project");
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"art-asset-plan",
|
||||
"生成透明核心美术图集",
|
||||
"mud-points-art-run",
|
||||
"agent-ready-task-scheduler",
|
||||
"生成透明核心美术图集",
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("start art runtime");
|
||||
runtime.observations.push(
|
||||
"canvas.asset_generate:failed · 平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let error = game_chat_fast_path_canvas_generation_error(
|
||||
&runtime,
|
||||
"game-chat 透明核心美术图集生成失败,拒绝退回纯代码核心画面",
|
||||
)
|
||||
.expect("mud point failure must stop the fast path");
|
||||
assert_eq!(error, GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND);
|
||||
assert!(!error.contains("operationId"));
|
||||
assert!(!error.contains("private-operation-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_html_uses_spritesheet_but_never_art_spec() {
|
||||
let temporary = tempfile::tempdir().expect("temporary project");
|
||||
|
||||
@@ -27,6 +27,9 @@ pub(in crate::agent) fn game_creator_agent_runtime_transient_provider_error_kind
|
||||
error: &platform_llm::LlmError,
|
||||
retry_autonomous_upstream_400: bool,
|
||||
) -> Option<&'static str> {
|
||||
if game_creator_agent_llm_error_is_mud_points_insufficient(error) {
|
||||
return None;
|
||||
}
|
||||
match error {
|
||||
platform_llm::LlmError::Timeout { .. } => Some("timeout"),
|
||||
platform_llm::LlmError::Connectivity { .. } => Some("connectivity"),
|
||||
@@ -181,6 +184,12 @@ pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message(
|
||||
agent_id: &str,
|
||||
error: &str,
|
||||
) -> String {
|
||||
if error.contains(GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND)
|
||||
|| game_creator_runtime_error_is_mud_points_insufficient(error)
|
||||
{
|
||||
return "泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。"
|
||||
.to_string();
|
||||
}
|
||||
let subject = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
"项目总控 Agent"
|
||||
} else {
|
||||
@@ -1559,6 +1568,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mud_point_insufficiency_interrupts_without_retry_and_uses_a_fixed_public_message() {
|
||||
for status_code in [409, 502] {
|
||||
let error = platform_llm::LlmError::Upstream {
|
||||
status_code,
|
||||
message: "泥点余额不足".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_transient_provider_error_kind(&error, false),
|
||||
None,
|
||||
"泥点余额不足不是可通过自动重试恢复的上游瞬态错误"
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_llm_error_public_summary(&error),
|
||||
GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND
|
||||
);
|
||||
let encoded = game_creator_agent_runtime_provider_error_with_transient_kind(
|
||||
&error,
|
||||
"agentLlm.project-supervisor",
|
||||
"规划",
|
||||
false,
|
||||
);
|
||||
assert!(!encoded.starts_with(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX));
|
||||
assert!(encoded.ends_with(GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND));
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_failure_conversation_message(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&encoded,
|
||||
),
|
||||
"泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。"
|
||||
);
|
||||
}
|
||||
|
||||
let provider_quota = platform_llm::LlmError::Upstream {
|
||||
status_code: 429,
|
||||
message: "insufficient_quota".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_transient_provider_error_kind(&provider_quota, false),
|
||||
Some("upstream-429"),
|
||||
"第三方 Provider quota 不能误分类为平台泥点不足"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_failure_conversation_message(
|
||||
"art-asset-plan",
|
||||
"canvas.asset_generate:failed · 平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id",
|
||||
),
|
||||
"泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_upstream_503_keeps_generic_classification_and_specific_safe_code() {
|
||||
let secret = ["sk", "provider-body-secret"].join("-");
|
||||
|
||||
@@ -926,7 +926,9 @@ fn platform_art_generation_observation_status(
|
||||
run_id: &str,
|
||||
error: &str,
|
||||
) -> &'static str {
|
||||
if platform_art_generation_error_needs_reconciliation(error)
|
||||
if game_creator_runtime_error_is_mud_points_insufficient(error) {
|
||||
"failed"
|
||||
} else if platform_art_generation_error_needs_reconciliation(error)
|
||||
|| game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id)
|
||||
{
|
||||
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
|
||||
@@ -990,4 +992,31 @@ mod platform_art_generation_observation_tests {
|
||||
"failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mud_point_insufficiency_is_a_definitive_failure_with_a_durable_generation_ledger() {
|
||||
let root = tempfile::tempdir().expect("create mud point observation status root");
|
||||
let ledger_directory = root
|
||||
.path()
|
||||
.join(".agent/runtime/canvas-generation-requests/art-director");
|
||||
fs::create_dir_all(&ledger_directory).expect("create durable generation ledger directory");
|
||||
fs::write(ledger_directory.join("run-accepted.json"), b"accepted")
|
||||
.expect("write durable generation ledger");
|
||||
|
||||
for error in [
|
||||
"平台图片生成任务失败:泥点余额不足;operationId=private-operation-id",
|
||||
"平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2",
|
||||
] {
|
||||
assert_eq!(
|
||||
platform_art_generation_observation_status(
|
||||
root.path(),
|
||||
"art-director",
|
||||
"run-accepted",
|
||||
error,
|
||||
),
|
||||
"failed",
|
||||
"明确的泥点不足不能因 durable ledger 存在而误入 reconciliation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1496,6 +1496,18 @@ export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) {
|
||||
return '正在执行当前任务';
|
||||
}
|
||||
|
||||
export const MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE =
|
||||
'泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。';
|
||||
|
||||
export function isMudPointInsufficientRuntimeError(message: string) {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
message.includes('泥点余额不足') ||
|
||||
message.includes('可消费泥点不足:') ||
|
||||
normalized.includes('kind=mud-points-insufficient')
|
||||
);
|
||||
}
|
||||
|
||||
export function projectRuntimeVisibleError(
|
||||
message: string,
|
||||
subject: string,
|
||||
@@ -1506,6 +1518,9 @@ export function projectRuntimeVisibleError(
|
||||
if (isRuntimeConfigMissingError(message)) {
|
||||
return '运行时配置未完成,请先打开配置';
|
||||
}
|
||||
if (isMudPointInsufficientRuntimeError(message)) {
|
||||
return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE;
|
||||
}
|
||||
const exhaustedUpstreamRetry = visibleMessage.match(
|
||||
/(?:^|[\s::])kind=upstream-(\d{3}) httpStatus=(\d{3}) fingerprint=[0-9a-f]{64} chars=\d+ retryAttempt=(\d+) maxRetries=(\d+) retryState=exhausted\s*$/,
|
||||
);
|
||||
|
||||
+49
-8
@@ -23,7 +23,9 @@ import type {
|
||||
} from '../../app/types';
|
||||
import {
|
||||
formatAgentRuntimeEvent,
|
||||
isMudPointInsufficientRuntimeError,
|
||||
isAgentRuntimeTerminalState,
|
||||
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
|
||||
projectNameFromPath,
|
||||
projectProfessionalAgentLabel,
|
||||
projectRuntimePlanProgress,
|
||||
@@ -89,6 +91,7 @@ export type GameChatSupervisorProgress = {
|
||||
currentWork: string;
|
||||
activeAgents: string[];
|
||||
evidence: GameChatProgressEvidence[];
|
||||
interruptionText: string | null;
|
||||
};
|
||||
|
||||
export type GameChatResultImage = {
|
||||
@@ -185,7 +188,7 @@ export function formatGameChatStageRecord(
|
||||
) {
|
||||
const terminalStatus =
|
||||
runtime.status === 'failed' || runtime.phase === 'failed'
|
||||
? '本轮失败'
|
||||
? progress.interruptionText || '本轮失败'
|
||||
: runtime.status === 'cancelled' || runtime.phase === 'cancelled'
|
||||
? '本轮已取消'
|
||||
: runtime.status === 'completed' || runtime.phase === 'completed'
|
||||
@@ -213,6 +216,34 @@ export function formatGameChatStageRecord(
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function gameChatMudPointInterruptionText(
|
||||
runtime: AgentRuntimeState,
|
||||
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>,
|
||||
) {
|
||||
const childRuntimes = Object.values(runtimeByAgentId).filter(
|
||||
(childRuntime): childRuntime is AgentRuntimeState =>
|
||||
childRuntime !== undefined &&
|
||||
childRuntime.parentAgentId === runtime.agentId &&
|
||||
childRuntime.parentRunId === runtime.runId &&
|
||||
[
|
||||
'agent-delegate',
|
||||
'agent-delegate-retry',
|
||||
'agent-ready-task-scheduler',
|
||||
].includes(childRuntime.source),
|
||||
);
|
||||
const relatedRuntimes = [
|
||||
runtime,
|
||||
...childRuntimes,
|
||||
];
|
||||
return relatedRuntimes.some(
|
||||
(relatedRuntime) =>
|
||||
relatedRuntime.error &&
|
||||
isMudPointInsufficientRuntimeError(relatedRuntime.error),
|
||||
)
|
||||
? MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isGameChatStageRecordMessage(text: string) {
|
||||
return text.startsWith(GAME_CHAT_STAGE_RECORD_PREFIX);
|
||||
}
|
||||
@@ -504,6 +535,10 @@ export function buildGameChatProgressEvidence(
|
||||
),
|
||||
activeAgents,
|
||||
evidence,
|
||||
interruptionText: gameChatMudPointInterruptionText(
|
||||
runtime,
|
||||
runtimeByAgentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -817,13 +852,19 @@ export function SupervisorChatOnlyView({
|
||||
synchronizingAcceptedRun ||
|
||||
(runtime && !isAgentRuntimeTerminalState(runtime)),
|
||||
);
|
||||
const status = runtimeError
|
||||
? projectRuntimeVisibleError(runtimeError, '项目总控 Agent', true)
|
||||
: synchronizingAcceptedRun
|
||||
? '已投递,正在同步 Agent Runner'
|
||||
: runtime
|
||||
? projectSupervisorChatRuntimeStatus(runtime)
|
||||
: workspaceStatus;
|
||||
const gameChatInterruptionText =
|
||||
gameChatMode && runtime
|
||||
? gameChatMudPointInterruptionText(runtime, runtimeByAgentId)
|
||||
: null;
|
||||
const status =
|
||||
gameChatInterruptionText ||
|
||||
(runtimeError
|
||||
? projectRuntimeVisibleError(runtimeError, '项目总控 Agent', true)
|
||||
: synchronizingAcceptedRun
|
||||
? '已投递,正在同步 Agent Runner'
|
||||
: runtime
|
||||
? projectSupervisorChatRuntimeStatus(runtime)
|
||||
: workspaceStatus);
|
||||
const headerStatus =
|
||||
gameChatMode && previewStatus === '未启动'
|
||||
? '预览未启动'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '../src/app/types';
|
||||
import {
|
||||
formatAgentRuntimeEvent,
|
||||
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
|
||||
mergeGameChatRuntimeResponseMessagesIntoHistory,
|
||||
projectRuntimeVisibleCurrentWork,
|
||||
projectRuntimeVisibleError,
|
||||
@@ -292,6 +293,36 @@ describe('Agent Runtime Provider 状态投影', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('明确说明泥点余额不足导致的中断并隐藏附加诊断', () => {
|
||||
for (const message of [
|
||||
'agentLlm.project-supervisor 规划调用 LLM 失败:kind=mud-points-insufficient',
|
||||
'平台图片生成任务失败:泥点余额不足;operationId=private-operation-id',
|
||||
]) {
|
||||
expect(projectRuntimeVisibleError(message, '项目总控 Agent', true)).toBe(
|
||||
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
|
||||
);
|
||||
expect(
|
||||
projectSupervisorVisibleConversationText(`后台任务失败:${message}`),
|
||||
).toBe(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
|
||||
}
|
||||
|
||||
const failedRuntime = {
|
||||
...providerRetryRuntime(),
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
error:
|
||||
'平台图片生成任务失败:泥点余额不足;operationId=private-operation-id',
|
||||
};
|
||||
const status = projectSupervisorChatRuntimeStatus(failedRuntime);
|
||||
expect(status).toBe(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
|
||||
expect(status).not.toContain('operationId');
|
||||
expect(status).not.toContain('private-operation-id');
|
||||
|
||||
expect(
|
||||
projectRuntimeVisibleError('上游余额不足', '项目总控 Agent', true),
|
||||
).toBe('项目总控 Agent 执行失败,请稍后重试');
|
||||
});
|
||||
|
||||
test('即使前缀含恶意上游正文也只展示严格字段派生的摘要', () => {
|
||||
const fingerprint = 'b'.repeat(64);
|
||||
const malicious =
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AgentRuntimeEventRecord,
|
||||
AgentRuntimeState,
|
||||
} from '../../src/app/types';
|
||||
import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE } from '../../src/features/agent-runtime/model';
|
||||
import {
|
||||
buildGameChatProgressEvidence,
|
||||
collectGameChatResultImages,
|
||||
@@ -2684,7 +2685,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
taskId: 'design-foundation',
|
||||
sessionId: 'design-session',
|
||||
runId: 'design-run',
|
||||
source: 'agent-delegate',
|
||||
source: 'agent-ready-task-scheduler',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: 'active-parent-run',
|
||||
updatedAt: 30,
|
||||
@@ -3574,6 +3575,52 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)');
|
||||
});
|
||||
|
||||
it('shows and archives the explicit mud point interruption from a failed art child runtime', () => {
|
||||
const rootRunId = 'game-chat-mud-point-root-run';
|
||||
const runtime = gameChatRuntimeState({
|
||||
runId: rootRunId,
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
error: '专业 Agent 执行失败',
|
||||
updatedAt: 9100,
|
||||
});
|
||||
const artRuntime = gameChatRuntimeState({
|
||||
agentId: 'art-asset-plan',
|
||||
taskId: 'art-asset-plan',
|
||||
sessionId: 'game-chat-mud-point-art-session',
|
||||
runId: 'game-chat-mud-point-art-run',
|
||||
source: 'agent-delegate',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: rootRunId,
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
error:
|
||||
'平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id',
|
||||
updatedAt: 9000,
|
||||
});
|
||||
const runtimeByAgentId = { 'art-asset-plan': artRuntime };
|
||||
|
||||
renderGameChatStatus({ runtime, runtimeByAgentId });
|
||||
|
||||
const status = screen.getByLabelText('最新状态').textContent ?? '';
|
||||
expect(status).toContain(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
|
||||
expect(status).not.toContain('operationId');
|
||||
expect(status).not.toContain('private-operation-id');
|
||||
|
||||
const progress = buildGameChatProgressEvidence(
|
||||
runtime,
|
||||
runtimeByAgentId,
|
||||
null,
|
||||
);
|
||||
if (!progress) {
|
||||
throw new Error('missing failed game-chat progress fixture');
|
||||
}
|
||||
const stageRecord = formatGameChatStageRecord(runtime, progress, []);
|
||||
expect(stageRecord).toContain(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
|
||||
expect(stageRecord).not.toContain('operationId');
|
||||
expect(stageRecord).not.toContain('private-operation-id');
|
||||
});
|
||||
|
||||
it('counts only the seven first-playable tasks in game-chat progress', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'game-chat-progress-total',
|
||||
@@ -3622,6 +3669,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
});
|
||||
const initialSupervisorOverrides = {
|
||||
runId: supervisorRunId,
|
||||
source: 'project-supervisor-game-chat',
|
||||
runProfile: 'autonomous-game-build' as const,
|
||||
status: 'running',
|
||||
phase: 'execution',
|
||||
@@ -3652,7 +3700,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
taskId: 'code-prototype',
|
||||
sessionId: 'code-prototype-progress-session',
|
||||
runId: 'code-prototype-progress-run',
|
||||
source: 'agent-delegate',
|
||||
source: 'agent-ready-task-scheduler',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: supervisorRunId,
|
||||
delegationId: 'code-prototype-progress-delegation',
|
||||
|
||||
@@ -5939,6 +5939,12 @@
|
||||
- 边界:Supervisor 的角色选择、并行委派、all-join、视觉返工、claim gate 和 repair 自然语言合同进入 Bundle;Bundle 不是可执行 graph,也不是生产 Skill,正式 DAG、权限、安全门和完成合同继续由 Rust 与校验后的项目协作策略掌控。
|
||||
- 一致性:原生工具目录从 `agent_runtime_native_executable_tools()` 生成,`mcp.call` 不混入静态原生目录;MCP 工具只从当前请求的动态 catalog 暴露。manifest 版本、section 覆盖、组合顺序和既有 Prompt 合同由测试锁定。
|
||||
|
||||
## 2026-08-03 AI 游戏生成泥点不足使用确定性中断说明
|
||||
|
||||
- 决策:钱包返回 `泥点余额不足` 或 `可消费泥点不足:...` 时,API 统一按 HTTP 409 业务冲突处理并只公开固定的“泥点余额不足”;Agent Runtime 转换为稳定原因 `mud-points-insufficient`,禁止进入瞬态 Provider 自动重试。
|
||||
- 恢复边界:泥点不足表示平台已经明确拒绝计费与生成,即使本地保留 accepted External Generation ledger,也必须标为 `failed`,不能因账本存在而进入 `needs-reconciliation`。充值后的新输入“继续”仍走现有失败 successor 合同,从原任务和当前已提交项目事实接着完成,不重放旧生成请求。
|
||||
- 展示与安全:game-chat 顶部状态、持久失败对话和 Supervisor 阶段记录统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”;公开层不得附带 operationId、URL、项目路径、密钥或上游响应正文。第三方 `insufficient_quota`、普通 409、402 与 429 不得误分类为平台泥点不足。
|
||||
|
||||
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包
|
||||
|
||||
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。
|
||||
|
||||
@@ -827,6 +827,7 @@ game-project/
|
||||
- 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。
|
||||
- game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。
|
||||
- `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。
|
||||
- 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。
|
||||
- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。
|
||||
|
||||
## 2026-07-31 长耗时与恢复收口
|
||||
|
||||
@@ -543,18 +543,20 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
|
||||
);
|
||||
let is_insufficient_balance = matches!(
|
||||
&error,
|
||||
SpacetimeClientError::Procedure(message) if message.contains("泥点余额不足")
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("泥点余额不足") || message.contains("可消费泥点不足:")
|
||||
);
|
||||
let status = if is_insufficient_balance {
|
||||
StatusCode::CONFLICT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
let public_message = is_insufficient_balance.then(|| message.clone());
|
||||
let public_message = is_insufficient_balance.then_some("泥点余额不足");
|
||||
let public_detail_message = public_message.unwrap_or(message.as_str());
|
||||
|
||||
let app_error = AppError::from_status(status).with_details(json!({
|
||||
"provider": "profile-wallet",
|
||||
"message": message,
|
||||
"message": public_detail_message,
|
||||
}));
|
||||
|
||||
if let Some(public_message) = public_message {
|
||||
@@ -740,20 +742,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn asset_operation_wallet_insufficient_balance_is_public_message() {
|
||||
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
|
||||
"泥点余额不足".to_string(),
|
||||
));
|
||||
for domain_message in [
|
||||
"泥点余额不足",
|
||||
"可消费泥点不足:需要 10,扣除退款占用后可用 2",
|
||||
] {
|
||||
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
|
||||
domain_message.to_string(),
|
||||
));
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||
assert_eq!(error.code(), "CONFLICT");
|
||||
assert_eq!(error.message(), "泥点余额不足");
|
||||
assert_eq!(
|
||||
error
|
||||
.details()
|
||||
.and_then(|details| details.get("message"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("泥点余额不足"),
|
||||
);
|
||||
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||
assert_eq!(error.code(), "CONFLICT");
|
||||
assert_eq!(error.message(), "泥点余额不足");
|
||||
assert_eq!(
|
||||
error
|
||||
.details()
|
||||
.and_then(|details| details.get("message"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("泥点余额不足"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user