import { answerRemainingInteractiveQuestions, assert, codedError, hashValue, isFailedTask, sleep, } from '../assertions/core.mjs'; import { countExactSecrets, disposableProjectPathVariants, duplicateCount, isNonEmptyString, sumObjectValues, } from '../assertions/runtime.mjs'; import { path } from '../dependencies.mjs'; import { claimOwnedRunner, ensureOwnedRunnerStableKillSupport, prepareIsolatedSuiteAppData, } from '../harness/app-data.mjs'; import { listFiles, readJson, readOptionalJsonl } from '../harness/io.mjs'; import { prepareCliBinary, runCli, startInteractiveCli, waitForInteractiveCliExit, waitForInteractiveCliOutput, writeInteractiveCliLine, } from '../harness/process.mjs'; import { seedDisposableProject } from '../harness/project.mjs'; import { agentConversationPath, countSecretsInProject, countSensitiveValuesBySurface, killRunnerOnce, readAllRuntimeEvents, readRunnerStatus, readTaskSnapshot, runnerBootId, waitForRunnerBootChange, } from '../harness/runtime.mjs'; import { projectSupervisorAgentId, state, userInputAnswerCanary, userInputAnswerText, userInputRuntimeSuite, } from '../runtime-state.mjs'; export function buildUserInputTaskPrompt() { return '为当前项目拟定一份首版角色规范方案。产品只有一项会实质改变方案的取舍尚未决定:美术表现应选轻量像素风还是手绘风。平台、受众、世界观和交付范围都已确定,不要顺带追问其它信息;不要猜测这项取舍,也不要修改文件、执行命令或调用外部工具。任务未完成时只需向用户澄清这一项取舍,获得明确答复后在同一轮给出三点简短方案。'; } export function assertUserInputTaskPrompt(task) { assert( task.includes('只有一项') && task.includes('不要猜测') && task.includes('同一轮'), 'user-input-required-task-boundary-missing', ); for (const forbidden of [ 'user.input_request', 'waiting-for-user-input', 'requestId', 'responseId', userInputAnswerCanary, ]) { assert(!task.includes(forbidden), 'user-input-task-recipe-leak'); } } export async function runUserInputRuntimeE2e() { await ensureOwnedRunnerStableKillSupport(); await seedDisposableProject(); state.cliBinary = await prepareCliBinary(); await prepareIsolatedSuiteAppData(); state.isolatedRunner.launchAttempted = true; const task = buildUserInputTaskPrompt(); assertUserInputTaskPrompt(task); state.initialTask = { chars: [...task].length, sha256: hashValue(task), }; state.userInputCliSession = startInteractiveCli([ '--swarm-chat', '--init', state.projectRoot, ]); await waitForInteractiveCliOutput( state.userInputCliSession, (output) => output.includes('Agent Swarm Chat'), 'user-input-cli-banner-timeout', 30_000, ); writeInteractiveCliLine(state.userInputCliSession, task); const pending = await waitForPendingUserInputRequest(); await waitForInteractiveCliOutput( state.userInputCliSession, (output) => output.includes(`[Needs input] agent=${projectSupervisorAgentId}`) && output.includes(`request=${pending.requestId}`), 'user-input-cli-question-timeout', 120_000, ); await claimOwnedRunner(); const beforeKillRunner = await readRunnerStatus(); state.userInput.oldRunnerBootId = runnerBootId(beforeKillRunner); assert( isNonEmptyString(state.userInput.oldRunnerBootId), 'user-input-runner-boot-before-kill-missing', ); await captureUserInputWaitingBoundary('before-kill'); await killRunnerOnce(); await runCli(['--agent-resume', state.projectRoot], { timeoutMs: 120_000 }); state.resumed = true; const restarted = await waitForRunnerBootChange( state.userInput.oldRunnerBootId, ); state.userInput.newRunnerBootId = runnerBootId(restarted); await claimOwnedRunner(restarted); await captureUserInputWaitingBoundary('after-restart'); await sleep(750); await captureUserInputWaitingBoundary('after-stable-window'); writeInteractiveCliLine(state.userInputCliSession, userInputAnswerText); await answerRemainingInteractiveQuestions(state.userInputCliSession); await waitForInteractiveCliOutput( state.userInputCliSession, (output) => output.includes(`[\u5df2\u56de\u7b54] ${pending.requestId}`), 'user-input-cli-answer-timeout', 60_000, ); await waitForUserInputRuntimeCompletion(); await waitForInteractiveCliOutput( state.userInputCliSession, (output) => output.includes('\nAgent> ') || output.includes( '[\u672c\u8f6e\u7ed3\u675f] 父 Agent 回复已完整流式输出。', ), 'user-input-cli-final-reply-timeout', 120_000, ); writeInteractiveCliLine(state.userInputCliSession, '/quit'); await waitForInteractiveCliExit(state.userInputCliSession, 30_000); state.userInputCliSession = null; state.identityStable = true; state.evidence = await validateUserInputRuntimeEvidence(); assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); } export async function readUserInputSidecars() { const files = ( await listFiles(path.join(state.projectRoot, '.agent/runtime/user-input')) ) .filter((file) => file.endsWith('.json')) .sort(); return Promise.all( files.map(async (file) => ({ file, record: await readJson(file) })), ); } export async function readUserInputPersistence() { const runtimeStatePath = path.join( state.projectRoot, '.agent/runtime/agents', `${projectSupervisorAgentId}.json`, ); const [ taskSnapshot, events, agentDb, activity, output, runtimeState, sidecars, ] = await Promise.all([ readTaskSnapshot(), readAllRuntimeEvents(), readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), readOptionalJsonl(path.join(state.projectRoot, '.agent/activity.jsonl')), readOptionalJsonl(path.join(state.projectRoot, '.agent/output.jsonl')), readJson(runtimeStatePath).catch(() => null), readUserInputSidecars(), ]); const sessionId = runtimeState?.sessionId ?? state.initialSessionId; const conversations = isNonEmptyString(sessionId) ? await readOptionalJsonl( agentConversationPath(projectSupervisorAgentId, sessionId), ) : []; return { taskSnapshot, events, agentDb, activity, output, runtimeState, sidecars, conversations, }; } export function userInputProviderLifecycleStarted(agentDb) { return agentDb.filter( (record) => record.recordType === 'agent.runtime.provider_request.lifecycle' && record.agentId === projectSupervisorAgentId && record.runId === state.initialRunId && record.status === 'started', ); } export async function waitForPendingUserInputRequest() { const deadline = Date.now() + 8 * 60 * 1000; while (Date.now() < deadline) { const persistence = await readUserInputPersistence(); const { runtimeState, sidecars, conversations, taskSnapshot, agentDb } = persistence; const latest = taskSnapshot.latest.find( (task) => task.agentId === projectSupervisorAgentId && task.runId === runtimeState?.runId, ); if (latest && isFailedTask(latest)) { throw codedError('user-input-runtime-failed-before-question'); } if ( runtimeState?.agentId === projectSupervisorAgentId && runtimeState.status === 'waiting-for-user-input' && runtimeState.phase === 'waiting-for-user-input' && sidecars.length === 1 ) { const record = sidecars[0].record; assert( record.schemaVersion === 'game-creator-runtime-user-input.v1' && record.agentId === projectSupervisorAgentId && record.runId === runtimeState.runId && record.sessionId === runtimeState.sessionId && record.status === 'pending' && Array.isArray(record.questions) && record.questions.length === 1 && record.responseId == null && Object.keys(record.answers ?? {}).length === 0, 'user-input-pending-sidecar-invalid', ); const questionMessages = conversations.filter( (message) => message.role === 'assistant' && message.messageId === record.questionMessageId, ); assert( questionMessages.length === 1 && questionMessages[0].content.includes(record.questions[0].question), 'user-input-question-conversation-invalid', ); state.initialRunId = runtimeState.runId; state.initialSessionId = runtimeState.sessionId; state.userInput.requestId = record.requestId; state.userInput.actionId = record.actionId; state.userInput.questionMessageId = record.questionMessageId; state.userInput.questionCount = record.questions.length; state.userInput.optionCount = record.questions.reduce( (count, question) => count + question.options.length, 0, ); state.userInput.privateValues = [ ...record.questions.map((question) => question.question), ...record.questions.flatMap((question) => question.options.map((option) => option.description), ), ].filter(isNonEmptyString); state.userInput.providerStartedBeforeKill = userInputProviderLifecycleStarted(agentDb).length; state.userInput.conversationCountBeforeKill = conversations.length; assert( state.userInput.providerStartedBeforeKill > 0, 'user-input-provider-planning-lifecycle-missing', ); return record; } if (runtimeState?.phase === 'needs-reconciliation') { throw codedError('user-input-runtime-needs-reconciliation'); } await sleep(250); } throw codedError('user-input-question-timeout'); } export async function captureUserInputWaitingBoundary(stage) { const persistence = await readUserInputPersistence(); const { runtimeState, sidecars, conversations, agentDb } = persistence; assert( runtimeState?.agentId === projectSupervisorAgentId && runtimeState.runId === state.initialRunId && runtimeState.sessionId === state.initialSessionId && runtimeState.status === 'waiting-for-user-input' && runtimeState.phase === 'waiting-for-user-input' && sidecars.length === 1 && sidecars[0].record.requestId === state.userInput.requestId && sidecars[0].record.actionId === state.userInput.actionId && sidecars[0].record.status === 'pending' && sidecars[0].record.responseId == null && conversations.length === state.userInput.conversationCountBeforeKill, `user-input-${stage}-waiting-boundary-invalid`, ); const providerStarted = userInputProviderLifecycleStarted(agentDb).length; assert( providerStarted === state.userInput.providerStartedBeforeKill, `user-input-${stage}-provider-called-while-waiting`, ); if (stage !== 'before-kill') { state.userInput.providerStartedAfterRestart = providerStarted; state.userInput.conversationCountAfterRestart = conversations.length; } } export async function waitForUserInputRuntimeCompletion() { const deadline = Date.now() + 8 * 60 * 1000; while (Date.now() < deadline) { const persistence = await readUserInputPersistence(); const { runtimeState, sidecars, taskSnapshot, conversations } = persistence; if (sidecars.length > 1) { throw codedError('user-input-unexpected-second-request'); } const latest = taskSnapshot.latest.find( (task) => task.agentId === projectSupervisorAgentId && task.runId === state.initialRunId, ); if (latest && isFailedTask(latest)) { throw codedError('user-input-runtime-failed-after-answer'); } if ( runtimeState?.runId === state.initialRunId && runtimeState.sessionId === state.initialSessionId && runtimeState.status === 'idle' && runtimeState.phase === 'completed' && latest?.status === 'completed' && latest.phase === 'completed' && sidecars.length === 1 && sidecars[0].record.status === 'answered' ) { const record = sidecars[0].record; state.userInput.responseId = record.responseId; state.userInput.answerMessageId = record.answerMessageId; const finalAssistants = conversations.filter( (message) => message.role === 'assistant' && message.messageId !== record.questionMessageId, ); if (finalAssistants.length === 1) return persistence; } if (runtimeState?.phase === 'needs-reconciliation') { throw codedError('user-input-runtime-needs-reconciliation-after-answer'); } await sleep(250); } throw codedError('user-input-completion-timeout'); } export function validateUserInputProviderLifecycle(agentDb) { const lifecycle = agentDb.filter( (record) => record.recordType === 'agent.runtime.provider_request.lifecycle' && record.agentId === projectSupervisorAgentId && record.runId === state.initialRunId, ); const byRequest = new Map(); for (const record of lifecycle) { assert( isNonEmptyString(record.requestId) && isNonEmptyString(record.requestKind) && isNonEmptyString(record.requestSlot), 'user-input-provider-lifecycle-identity-invalid', ); const records = byRequest.get(record.requestId) ?? []; records.push(record); byRequest.set(record.requestId, records); } for (const records of byRequest.values()) { assert( records.length === 2 && records[0].status === 'started' && ['completed', 'failed', 'interrupted'].includes(records[1].status) && records[0].requestKind === records[1].requestKind && records[0].requestSlot === records[1].requestSlot && records[0].runId === records[1].runId, 'user-input-provider-lifecycle-sequence-invalid', ); } const started = lifecycle.filter((record) => record.status === 'started'); assert( started.length >= 2 && started.length === byRequest.size && state.userInput.providerStartedBeforeKill === state.userInput.providerStartedAfterRestart, 'user-input-provider-lifecycle-count-invalid', ); return { requestIdentityCount: byRequest.size, startedCount: started.length, terminalCount: lifecycle.length - started.length, }; } export async function validateUserInputRuntimeEvidence() { const persistence = await readUserInputPersistence(); const { taskSnapshot, events, agentDb, activity, output, runtimeState, sidecars, conversations, } = persistence; assert(sidecars.length === 1, 'user-input-sidecar-count-invalid'); const sidecar = sidecars[0].record; const latest = taskSnapshot.latest.find( (task) => task.agentId === projectSupervisorAgentId && task.runId === state.initialRunId, ); assert( runtimeState?.agentId === projectSupervisorAgentId && runtimeState.runId === state.initialRunId && runtimeState.sessionId === state.initialSessionId && runtimeState.status === 'idle' && runtimeState.phase === 'completed' && latest?.status === 'completed' && latest.phase === 'completed', 'user-input-final-runtime-identity-invalid', ); assert( sidecar.schemaVersion === 'game-creator-runtime-user-input.v1' && sidecar.agentId === projectSupervisorAgentId && sidecar.runId === state.initialRunId && sidecar.sessionId === state.initialSessionId && sidecar.requestId === state.userInput.requestId && sidecar.actionId === state.userInput.actionId && sidecar.status === 'answered' && sidecar.responseId === state.userInput.responseId && sidecar.questionMessageId === state.userInput.questionMessageId && sidecar.answerMessageId === state.userInput.answerMessageId && sidecar.questions.length === 1 && Object.keys(sidecar.answers).length === 1 && Object.values(sidecar.answers)[0] === userInputAnswerText, 'user-input-final-sidecar-invalid', ); const questionMessages = conversations.filter( (message) => message.role === 'assistant' && message.messageId === sidecar.questionMessageId, ); const answerMessages = conversations.filter( (message) => message.role === 'user' && message.messageId === sidecar.answerMessageId, ); const finalAssistants = conversations.filter( (message) => message.role === 'assistant' && message.messageId !== sidecar.questionMessageId, ); assert( questionMessages.length === 1 && answerMessages.length === 1 && answerMessages[0].content.includes(userInputAnswerCanary) && finalAssistants.length === 1, 'user-input-conversation-cardinality-invalid', ); const duplicateMessageCount = duplicateCount( conversations.map((message) => message.messageId).filter(Boolean), ); assert(duplicateMessageCount === 0, 'user-input-duplicate-message-identity'); const observations = agentDb.filter( (record) => record.recordType === 'agent.runtime.user_input.answered' && record.agentId === projectSupervisorAgentId && record.runId === state.initialRunId && record.actionId === sidecar.actionId && record.requestId === sidecar.requestId, ); assert( observations.length === 1 && JSON.stringify(observations[0]).includes('answerCount=1'), 'user-input-public-observation-count-invalid', ); const lifecycle = validateUserInputProviderLifecycle(agentDb); const completedAudits = agentDb.filter( (record) => record.recordType === 'agent.runtime.completed' && record.agentId === projectSupervisorAgentId && record.runId === state.initialRunId, ); assert( completedAudits.length === 1, 'user-input-completed-audit-count-invalid', ); const finalizationFiles = ( await listFiles( path.join(state.projectRoot, '.agent/runtime/finalizations'), ) ).filter((file) => file.endsWith('.json')); assert( finalizationFiles.length === 0, 'user-input-finalization-journal-present', ); assert( countExactSecrets( Buffer.from(finalAssistants.map((message) => message.content).join('\n')), disposableProjectPathVariants(), ) === 0, 'user-input-final-assistant-project-path-leak', ); const publicSurfaces = { event: events, agentDb, activity, output, runtimeState, }; const privateValues = [ userInputAnswerCanary, userInputAnswerText, ...state.userInput.privateValues, ].filter(isNonEmptyString); const taskPrivateValues = privateValues.filter( (value) => !buildUserInputTaskPrompt().includes(value), ); const privateBodyPublicCounts = countSensitiveValuesBySurface( publicSurfaces, privateValues, 'user-input-private-body-public', ); const taskPrivateBodyPublicCounts = countSensitiveValuesBySurface( { task: taskSnapshot.all }, taskPrivateValues, 'user-input-private-body-public', ); const apiKeyPublicCounts = countSensitiveValuesBySurface( { task: taskSnapshot.all, ...publicSurfaces }, state.secrets, 'user-input-api-key-public', ); const projectPathPublicCounts = countSensitiveValuesBySurface( { task: taskSnapshot.all, ...publicSurfaces }, disposableProjectPathVariants(), 'user-input-project-path-public', ); const sidecarSecretLeakCount = countExactSecrets( Buffer.from(JSON.stringify(sidecar)), state.secrets, ); assert( sidecarSecretLeakCount === 0, 'user-input-sidecar-secret-leak-detected', ); const secretLeakCount = await countSecretsInProject( state.projectRoot, state.secrets, ); assert(secretLeakCount === 0, 'user-input-project-secret-leak-detected'); return { scenario: 'project-supervisor-needs-input-runner-restart', targetAgentId: projectSupervisorAgentId, providerModel: 'gpt-5.5', isolatedAppDataUsed: true, formalConfigCliCallCount: state.isolatedRunner.sourceConfigCliCallCount, sourceRunnerEndpointUnchanged: false, sourceConfigHardlinkCount: state.isolatedRunner.configLinks.length, sourceConfigLinksVerified: false, taskCount: taskSnapshot.all.length, eventCount: events.length, agentDbRecordCount: agentDb.length, conversationMessageCount: conversations.length, targetRunCount: new Set( taskSnapshot.all .filter((task) => task.agentId === projectSupervisorAgentId) .map((task) => task.runId), ).size, stableSessionCount: new Set( taskSnapshot.all .filter((task) => task.agentId === projectSupervisorAgentId) .map((task) => task.sessionId), ).size, userInputSidecarCount: sidecars.length, userInputQuestionCount: sidecar.questions.length, userInputOptionCount: state.userInput.optionCount, userInputAnswerCount: Object.keys(sidecar.answers).length, userInputQuestionMessageCount: questionMessages.length, userInputAnswerMessageCount: answerMessages.length, finalAssistantCount: finalAssistants.length, completedAuditCount: completedAudits.length, toolObservationCount: observations.length, providerRequestIdentityCount: lifecycle.requestIdentityCount, providerLifecycleStartedCount: lifecycle.startedCount, providerLifecycleTerminalCount: lifecycle.terminalCount, providerStartedBeforeRunnerKill: state.userInput.providerStartedBeforeKill, providerStartedAfterRunnerRestart: state.userInput.providerStartedAfterRestart, providerCalledWhileWaiting: false, conversationCountBeforeRunnerKill: state.userInput.conversationCountBeforeKill, conversationCountAfterRunnerRestart: state.userInput.conversationCountAfterRestart, runnerBootChanged: state.userInput.oldRunnerBootId !== state.userInput.newRunnerBootId, duplicateMessageCount, finalizationJournalCount: finalizationFiles.length, privateBodyPublicLeakCount: sumObjectValues(privateBodyPublicCounts) + sumObjectValues(taskPrivateBodyPublicCounts), apiKeyPublicLeakCount: sumObjectValues(apiKeyPublicCounts), projectPathPublicLeakCount: sumObjectValues(projectPathPublicCounts), projectPathPublicSurfaceCount: Object.keys(projectPathPublicCounts).length, userInputSidecarSecretLeakCount: sidecarSecretLeakCount, userInputReportLeakCount: state.userInput.reportLeakCount, userInputRunnerKillMethod: null, userInputRunnerPidfdClaimCount: state.isolatedRunner.pidfdClaimCount, userInputRunnerPidfdSignalCount: state.isolatedRunner.pidfdSignalCount, userInputRunnerStopped: false, userInputAppDataCleanupPerformed: false, secretLeakCount, lureLeakCount: state.lureLeakCount, paths: [ '.agent/runtime/user-input', '.agent/runtime/tasks', '.agent/runtime/events', '.agent/agent.db', '.agent/conversations', ], }; } export async function collectPartialUserInputEvidence() { const persistence = await readUserInputPersistence(); return { taskCount: persistence.taskSnapshot.all.length, eventCount: persistence.events.length, agentDbRecordCount: persistence.agentDb.length, conversationMessageCount: persistence.conversations.length, userInputSidecarCount: persistence.sidecars.length, userInputQuestionCount: persistence.sidecars[0]?.record?.questions?.length ?? 0, userInputAnswerCount: Object.keys( persistence.sidecars[0]?.record?.answers ?? {}, ).length, finalAssistantCount: persistence.conversations.filter( (message) => message.role === 'assistant' && message.messageId !== persistence.sidecars[0]?.record?.questionMessageId, ).length, }; } export function emptyUserInputEvidence() { return { scenario: 'project-supervisor-needs-input-runner-restart', targetAgentId: projectSupervisorAgentId, providerModel: 'gpt-5.5', isolatedAppDataUsed: false, formalConfigCliCallCount: 0, sourceRunnerEndpointUnchanged: false, sourceConfigHardlinkCount: 0, sourceConfigLinksVerified: false, taskCount: 0, eventCount: 0, agentDbRecordCount: 0, conversationMessageCount: 0, targetRunCount: 0, stableSessionCount: 0, userInputSidecarCount: 0, userInputQuestionCount: 0, userInputOptionCount: 0, userInputAnswerCount: 0, userInputQuestionMessageCount: 0, userInputAnswerMessageCount: 0, finalAssistantCount: 0, completedAuditCount: 0, toolObservationCount: 0, providerRequestIdentityCount: 0, providerLifecycleStartedCount: 0, providerLifecycleTerminalCount: 0, providerStartedBeforeRunnerKill: 0, providerStartedAfterRunnerRestart: 0, providerCalledWhileWaiting: false, conversationCountBeforeRunnerKill: 0, conversationCountAfterRunnerRestart: 0, runnerBootChanged: false, duplicateMessageCount: 0, finalizationJournalCount: 0, privateBodyPublicLeakCount: 0, apiKeyPublicLeakCount: 0, projectPathPublicLeakCount: 0, projectPathPublicSurfaceCount: 0, userInputSidecarSecretLeakCount: 0, userInputReportLeakCount: 0, userInputRunnerKillMethod: null, userInputRunnerPidfdClaimCount: 0, userInputRunnerPidfdSignalCount: 0, userInputRunnerStopped: false, userInputAppDataCleanupPerformed: false, secretLeakCount: 0, lureLeakCount: 0, paths: [], }; } export function isUserInputRuntimeSuite() { return state.suite === userInputRuntimeSuite; }