GDD 审批卡不再被两张伪卡压住
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Successful in 3m40s
Project CI / Native shell tests (pull_request) Successful in 19m47s

立项策划提交 Fast GDD 后,界面上同时冒出三样东西:一句「待回答问题未能读取,请稍后
重试」、一张 plan.submit_gdd 的拒绝/确认卡,以及被压在下面的 GddApprovalCard。前两样
都是伪的。

plan.submit_gdd 的 pending 是审批卡的载体,不是等待批准的动作——planning/pending.json
的 submission.pendingActionId 指向的正是它,交互面是审批卡的批准/修改/退回。它和
user.input_request 是同一性质,而且运行时策略明确拒绝把它转成通用确认 pending
(「Runtime-owned create-only 提交」),那个确认按钮点下去必然失败。所以把载体判据从只认
user.input_request 扩成两个工具都认,并改名 agentRuntimePendingActionHasDedicatedCard。

同时补上第四个渲染面:协作 Agent 列表也无条件把 pendingToolAction 画成通用确认卡,上一次
只改了开发者面板、总控概览卡和聊天视图三处。

「待回答问题未能读取」则是另一回事:审批等待复用了 waiting-for-user-input 这个 phase,但
它没有 userInputRequest,只看 phase 就会把一次正常的等待报成读取失败。给
ProjectSupervisorRuntimePanel 加一个 planGddAwaitingDecision 入参,由两个调用处按
planGddState.pendingApproval 传下来,等审批时不再报这句错。

新增用例同时断言三条:审批未决时不出现「待回答问题未能读取」、不出现 plan.submit_gdd
确认卡、审批卡的批准按钮在位。两条判据都做过 A/B——各自关掉后对应的伪卡都会冒出来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 07:53:15 +00:00
parent 65c3d172b2
commit c1a8ca6481
5 changed files with 111 additions and 22 deletions
@@ -858,20 +858,34 @@ export function agentRuntimeNeedsUserInput(
}
export const AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL = 'user.input_request';
export const AGENT_RUNTIME_PLAN_SUBMIT_GDD_TOOL = 'plan.submit_gdd';
// `user.input_request` 的 pending 不是等待批准的动作,而是问题本身的载体:子 Agent 以
// needs-user-input 终态退出后,Runtime 在 parent-wake 屏障处按信封原文构造这个 pending
// `ensure_static_delegate_user_input_wait_at_locked`),同一份问题再投影成
// `userInputRequest`。它也从来不在 `GAME_CREATION_APP_COMMANDS` 的 confirm 集合里,
// 没有任何「确认/拒绝」语义。
// 有两个 pending 动作不是等待批准的动作,而是某张专用卡的载体:Runtime 把内容停在
// pending 上,真正的交互面是另一个组件。
//
// 通用待确认卡按 `tool` + `inputSummary` 渲染,套到它身上就是把同一个请求画两遍——标题
// 是原始工具名,副标题是 `questionCount=… · questionsSha256=…` 这种给日志看的取证摘要。
// 真正的交互面是问答卡,所以这里统一把通用确认面判掉
export function agentRuntimePendingActionIsUserInputCarrier(
// - `user.input_request`:子 Agent 以 needs-user-input 终态退出后,Runtime 在 parent-wake
// 屏障处按信封原文构造这个 pending`ensure_static_delegate_user_input_wait_at_locked`),
// 同一份问题再投影成 `userInputRequest`,交互面是问答卡
// - `plan.submit_gdd`:策划子 Agent 提交 Fast GDD 后,`planning/pending.json` 的
// `submission.pendingActionId` 指向的正是这个 pending,交互面是 `GddApprovalCard`
// 的批准 / 修改 / 退回。
//
// 两者都没有「确认 / 拒绝」语义:`user.input_request` 从来不在
// `GAME_CREATION_APP_COMMANDS` 的 confirm 集合里;`plan.submit_gdd` 更是被运行时策略明确
// 拒绝转成通用确认 pending(「Runtime-owned create-only 提交」),点确认必然失败。
//
// 通用待确认卡按 `tool` + `inputSummary` 渲染,套到它们身上就是把同一个请求画两遍——标题
// 是原始工具名,副标题是 `questionCount=… · questionsSha256=…` 这种给日志看的取证摘要,
// 还会把真正该看的那张卡压在下面。所以这里统一把通用确认面判掉。
const AGENT_RUNTIME_DEDICATED_CARD_TOOLS = new Set([
AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL,
AGENT_RUNTIME_PLAN_SUBMIT_GDD_TOOL,
]);
export function agentRuntimePendingActionHasDedicatedCard(
action: AgentRuntimePendingToolActionSummary | null | undefined,
) {
return action?.tool === AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL;
return Boolean(action && AGENT_RUNTIME_DEDICATED_CARD_TOOLS.has(action.tool));
}
export function matchingAgentRuntimeForSteer(
@@ -29,7 +29,7 @@ import {
agentRuntimeCanRetry,
agentRuntimeNeedsUserInput,
agentRuntimeNextStepFromPhase,
agentRuntimePendingActionIsUserInputCarrier,
agentRuntimePendingActionHasDedicatedCard,
agentRuntimePlanStepText,
agentRuntimeWaitingOnFromPhase,
createAgentRuntimeUserInputResponseId,
@@ -399,18 +399,18 @@ export function AgentRuntimeStatusPanel({
!agentGoalStatusIsPaused(runtime.phase) &&
!pendingToolAction &&
Boolean(onRetryRuntimeTask);
const pendingActionIsUserInputCarrier =
agentRuntimePendingActionIsUserInputCarrier(pendingToolAction);
const pendingActionHasDedicatedCard =
agentRuntimePendingActionHasDedicatedCard(pendingToolAction);
const canConfirm =
Boolean(runtime.runId) &&
Boolean(pendingToolAction?.actionId) &&
!pendingActionIsUserInputCarrier &&
!pendingActionHasDedicatedCard &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onConfirmRuntimeTask);
const canReject =
Boolean(runtime.runId) &&
Boolean(pendingToolAction?.actionId) &&
!pendingActionIsUserInputCarrier &&
!pendingActionHasDedicatedCard &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onRejectRuntimeTask);
const canCompact =
@@ -675,6 +675,7 @@ export function ProjectSupervisorRuntimePanel({
runtimeByAgentId,
controlBusy,
readOnly = false,
planGddAwaitingDecision = false,
professionalResultsByAgentId,
onToolAction,
onSupervisorRetry,
@@ -687,6 +688,15 @@ export function ProjectSupervisorRuntimePanel({
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>;
controlBusy: boolean;
readOnly?: boolean;
/**
* `GddApprovalCard` 是否正在等用户做决定(`planning/pending.json` 有
* `awaiting_decision` 的 pending)。
*
* 立项策划的审批等待复用了 `waiting-for-user-input` 这个 phase,但它没有
* `userInputRequest`——真正的交互面是审批卡。只看 phase 的话这里会退到「待回答问题未能
* 读取」那句错误,把一次正常的等待报成读取失败,还压在审批卡上面。
*/
planGddAwaitingDecision?: boolean;
professionalResultsByAgentId: Record<
string,
ProjectAgentResultSummary | undefined
@@ -781,8 +791,8 @@ export function ProjectSupervisorRuntimePanel({
const pendingActionPresentation = pendingToolAction
? projectSupervisorPendingActionPresentation(pendingToolAction)
: null;
const pendingActionIsUserInputCarrier =
agentRuntimePendingActionIsUserInputCarrier(pendingToolAction);
const pendingActionHasDedicatedCard =
agentRuntimePendingActionHasDedicatedCard(pendingToolAction);
const userInputRequest = runtime?.userInputRequest ?? null;
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
const needsSupervisorReconciliation = Boolean(
@@ -960,7 +970,7 @@ export function ProjectSupervisorRuntimePanel({
) : null}
{pendingToolAction &&
pendingActionPresentation &&
!pendingActionIsUserInputCarrier &&
!pendingActionHasDedicatedCard &&
!readOnly &&
!needsSupervisorReconciliation ? (
<div
@@ -1001,7 +1011,11 @@ export function ProjectSupervisorRuntimePanel({
? agentRuntimePlanStepText(progress.active)
: '';
const professionalPendingAction =
professionalRuntime.pendingToolAction ?? null;
agentRuntimePendingActionHasDedicatedCard(
professionalRuntime.pendingToolAction,
)
? null
: (professionalRuntime.pendingToolAction ?? null);
const professionalResult =
professionalResultsByAgentId[professionalRuntime.agentId];
const dynamicArtRetryUnsupported =
@@ -1230,7 +1244,7 @@ export function ProjectSupervisorRuntimePanel({
controlBusy={controlBusy}
onSubmit={onUserInput}
/>
) : needsUserInput && !readOnly ? (
) : needsUserInput && !planGddAwaitingDecision && !readOnly ? (
<p className="agent-runtime-user-input-missing" role="status">
</p>
@@ -1295,7 +1309,7 @@ export function ProjectSupervisorRuntimeControls({
) => void | Promise<void>;
}) {
const pendingToolAction = runtime?.pendingToolAction ?? null;
const confirmableToolAction = agentRuntimePendingActionIsUserInputCarrier(
const confirmableToolAction = agentRuntimePendingActionHasDedicatedCard(
pendingToolAction,
)
? null
@@ -150,7 +150,10 @@ export function ProjectSupervisorView({
) : null}
</div>
{directCodex ? null : (
<ProjectSupervisorRuntimePanel {...runtimePanelProps} />
<ProjectSupervisorRuntimePanel
{...runtimePanelProps}
planGddAwaitingDecision={Boolean(planGddState?.pendingApproval)}
/>
)}
{pendingCommand ? (
<div className="pending-command">
@@ -866,6 +866,7 @@ export function ProjectWorkspaceChatPane({
error={projectSupervisorRuntimeError}
runtimeByAgentId={agentRuntimeById}
controlBusy={chatAgentBusy}
planGddAwaitingDecision={Boolean(planGddState?.pendingApproval)}
professionalResultsByAgentId={professionalAgentResultsById}
onToolAction={handleProjectSupervisorToolAction}
onSupervisorRetry={handleProjectSupervisorRetry}
@@ -174,4 +174,61 @@ export function registerPlanGddApprovalTests() {
expect(textarea.value).toBe('战斗节奏太慢,请压缩到三个回合');
expect(harness.planGddDecisionCalls).toHaveLength(0);
});
it('shows only the approval card while the GDD decision is outstanding', async () => {
// 审批等待复用了 waiting-for-user-input 这个 phase,但它没有 userInputRequest;而
// 策划子 Agent 的 plan.submit_gdd pending 正是 planning/pending.json 里
// submission.pendingActionId 指向的那一个,交互面就是审批卡本身。两处都套通用面的话,
// 用户会同时看到一句「待回答问题未能读取」和一张点了必然失败的 plan.submit_gdd
// 确认卡,把真正该看的审批卡压在下面。
const supervisorRunId = 'plan-root-awaiting-approval-run';
let collaboratingRuntimes: Array<Record<string, unknown>> = [];
const harness = createProjectSupervisorRuntimeHarness({
expectedRunProfile: 'standard',
initialRuntime: {
runId: supervisorRunId,
source: 'project-supervisor-plan',
runProfile: 'standard',
status: 'running',
phase: 'waiting-for-user-input',
currentTask: '剧情向恋爱养成游戏',
waitingOn: '用户在审批卡选择批准、修改或退回',
nextStep: '等待 decide_game_creator_plan_gdd',
updatedAt: 8000,
},
runtimeMapLoader: async () => collaboratingRuntimes,
});
collaboratingRuntimes = [
harness.runtimeState({
agentId: 'project-planning',
taskId: 'project-planning',
sessionId: 'agent-session-project-planning',
runId: 'delegated-delegation-344abab06ded0da1cf572fa3',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: supervisorRunId,
delegationId: 'delegation-344abab06ded0da1cf572fa3',
runProfile: 'standard',
status: 'idle',
phase: 'completed',
currentTask: '完成立项策划并给出 Fast GDD',
pendingToolAction: {
actionId: 'action-0123456789abcdef01234567',
actionFingerprint: 'a'.repeat(64),
tool: 'plan.submit_gdd',
inputSummary: null,
reason: '提交完整原创 MVP Fast GDD。',
requestedAt: 8000,
},
updatedAt: 8000,
}),
];
harness.setPlanGddState(createPlanGddStateView());
await mountApprovalCard(harness);
expect(screen.queryByText('待回答问题未能读取,请稍后重试。')).toBeNull();
expect(screen.queryByText(/plan\.submit_gdd/)).toBeNull();
expect(screen.queryByLabelText('立项策划 Agent待确认动作')).toBeNull();
expect(screen.getByRole('button', { name: '批准 v1' })).not.toBeNull();
});
}