diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 8aa3baef1..a24dac387 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -20,6 +20,10 @@ const sentinelSchema = 'genarrative-agent-runtime-real-e2e-disposable.v1'; const goalAppDataSentinelFileName = '.agent-runtime-real-e2e-goal-appdata.json'; const goalAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-goal-appdata.v1'; +const responseStreamAppDataSentinelFileName = + '.agent-runtime-real-e2e-response-stream-appdata.json'; +const responseStreamAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-response-stream-appdata.v1'; const mainAgentId = 'code-prototype'; const requestedRunId = `real-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; const visibleText = 'GENARRATIVE_REAL_E2E_VISIBLE'; @@ -38,7 +42,14 @@ const commandRootErrorMarker = `real-e2e-root-${randomUUID().replaceAll('-', '') const commandRootErrorLine = 170; const commandDiagnosticLineCount = 240; const goalRuntimeSuite = 'goal-runtime'; +const responseStreamSuite = 'response-stream'; const goalSessionId = `agent-session-${mainAgentId}`; +const responseStreamThinkingCanary = `GENARRATIVE_RESPONSE_STREAM_THINKING_${randomUUID().replaceAll('-', '')}`; +const responseStreamThinkingMarkers = [ + '', + '', + responseStreamThinkingCanary, +]; const goalInitialMarker = `GENARRATIVE_GOAL_REVISION_ONE_${randomUUID() .replaceAll('-', '') .slice(0, 16)}`; @@ -180,6 +191,24 @@ class BlockedError extends Error { } } +const isolatedRunnerState = { + appDataDir: null, + ownerToken: null, + createdAt: 0, + current: null, + configLinks: [], + launchAttempted: false, + pidfdClaimCount: 0, + pidfdSignalCount: 0, + stopped: false, + cleanupPerformed: false, + streamOverrideCreated: false, + sourceConfigCliCallCount: 0, + sourceEndpointSnapshot: null, + sourceRunnerEndpointUnchanged: false, + sourceConfigLinksVerified: false, +}; + const state = { status: 'FAIL', suite: null, @@ -209,6 +238,7 @@ const state = { sentinelToken: null, cliBinary: null, runtimeConfigDir: null, + isolatedRunner: isolatedRunnerState, runnerKilled: false, resumed: false, identityStable: false, @@ -242,18 +272,20 @@ const state = { editedGoalSnapshotFingerprint: null, initialMarkerAbsenceCheckCount: 0, monitoredWriteActionIds: new Set(), - runner: { - appDataDir: null, - ownerToken: null, - createdAt: 0, - current: null, - configLinks: [], - launchAttempted: false, - pidfdClaimCount: 0, - pidfdSignalCount: 0, - stopped: false, - cleanupPerformed: false, - }, + runner: isolatedRunnerState, + }, + responseStream: { + effectiveStreamEnabled: false, + confirmedProjectVerifyCount: 0, + pollCount: 0, + observedSnapshots: [], + lastSnapshot: null, + firstTerminalPoll: null, + finalText: null, + finalRequestSlot: null, + finalResponseRevision: null, + publicLeakCount: 0, + reportLeakCount: 0, }, confirmedActionIds: new Set(), cleanupPerformed: false, @@ -305,13 +337,14 @@ try { state.runtimeConfigDir = state.options.configDir; if (isProcessSessionSuite()) state.evidence = emptyProcessEvidence(); if (isGoalRuntimeSuite()) state.evidence = emptyGoalEvidence(); + if (isResponseStreamSuite()) state.evidence = emptyResponseStreamEvidence(); const loaded = await loadConfig(state.options.configDir); state.secrets = loaded.secrets; state.transcriptScanner = new StreamingSecretScanner(state.secrets); state.config = await checkPrerequisites(loaded.config); const required = - isProcessSessionSuite() || isGoalRuntimeSuite() + isProcessSessionSuite() || isIsolatedRunnerSuite() ? ['llmConfigured'] : ['llmConfigured', 'chromeAvailable']; if (state.suite === 'full') { @@ -325,6 +358,8 @@ try { } else { if (isGoalRuntimeSuite()) { await runGoalRuntimeE2e(); + } else if (isResponseStreamSuite()) { + await runResponseStreamE2e(); } else if (isProcessSessionSuite()) { await runProcessSessionE2e(); } else { @@ -343,31 +378,57 @@ try { recordError(error?.code ?? 'unexpected-error', error); } finally { cleanupInProgress = true; - if (isGoalRuntimeSuite() && state.goal.runner.appDataDir) { + if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) { try { - await stopOwnedGoalRunner(); - state.goal.runner.stopped = true; - state.goal.runner.cleanupPerformed = await removeGoalSuiteAppData(); - if (!state.goal.runner.cleanupPerformed) { + await stopOwnedIsolatedRunner(); + state.isolatedRunner.stopped = true; + state.isolatedRunner.cleanupPerformed = + await removeIsolatedSuiteAppData(); + if (!state.isolatedRunner.cleanupPerformed) { state.status = 'FAIL'; - recordError('goal-appdata-cleanup-sentinel-missing'); + recordError('isolated-appdata-cleanup-sentinel-missing'); } } catch (error) { state.status = 'FAIL'; - recordError('goal-owned-runner-cleanup-failed', error); - await closeGoalRunnerKillHandle( - state.goal.runner.current?.killHandle, + recordError('isolated-owned-runner-cleanup-failed', error); + await closeOwnedRunnerKillHandle( + state.isolatedRunner.current?.killHandle, ).catch(() => {}); } - state.evidence.goalRunnerStopped = state.goal.runner.stopped; - state.evidence.goalAppDataCleanupPerformed = - state.goal.runner.cleanupPerformed; - state.evidence.goalRunnerKillMethod = - state.goal.runner.pidfdClaimCount > 0 ? 'linux-pidfd' : null; - state.evidence.goalRunnerPidfdClaimCount = - state.goal.runner.pidfdClaimCount; - state.evidence.goalRunnerPidfdSignalCount = - state.goal.runner.pidfdSignalCount; + const killMethod = + state.isolatedRunner.pidfdClaimCount > 0 ? 'linux-pidfd' : null; + if (isGoalRuntimeSuite()) { + state.evidence.goalRunnerStopped = state.isolatedRunner.stopped; + state.evidence.goalAppDataCleanupPerformed = + state.isolatedRunner.cleanupPerformed; + state.evidence.goalRunnerKillMethod = killMethod; + state.evidence.goalRunnerPidfdClaimCount = + state.isolatedRunner.pidfdClaimCount; + state.evidence.goalRunnerPidfdSignalCount = + state.isolatedRunner.pidfdSignalCount; + } else { + state.evidence.responseStreamRunnerStopped = state.isolatedRunner.stopped; + state.evidence.responseStreamAppDataCleanupPerformed = + state.isolatedRunner.cleanupPerformed; + state.evidence.responseStreamRunnerKillMethod = killMethod; + state.evidence.responseStreamRunnerPidfdClaimCount = + state.isolatedRunner.pidfdClaimCount; + state.evidence.responseStreamRunnerPidfdSignalCount = + state.isolatedRunner.pidfdSignalCount; + state.evidence.formalConfigCliCallCount = + state.isolatedRunner.sourceConfigCliCallCount; + state.evidence.sourceRunnerEndpointUnchanged = + state.isolatedRunner.sourceRunnerEndpointUnchanged; + state.evidence.sourceConfigHardlinkCount = + state.isolatedRunner.configLinks.length; + state.evidence.sourceConfigLinksVerified = + state.isolatedRunner.sourceConfigLinksVerified; + state.evidence.isolatedAppDataUsed = true; + if (state.isolatedRunner.sourceConfigCliCallCount > 0) { + state.status = 'FAIL'; + recordError('response-stream-formal-config-cli-call-detected'); + } + } } if (isGoalRuntimeSuite() && state.projectRoot && state.status !== 'PASS') { try { @@ -379,6 +440,16 @@ try { recordError('goal-partial-evidence-read-failed', error); } } + if (isResponseStreamSuite() && state.projectRoot && state.status !== 'PASS') { + try { + state.evidence = { + ...state.evidence, + ...(await collectPartialResponseStreamEvidence()), + }; + } catch (error) { + recordError('response-stream-partial-evidence-read-failed', error); + } + } if (state.projectRoot && state.secrets.length > 0) { try { state.projectLeakCount = await countSecretsInProject( @@ -401,14 +472,14 @@ try { state.status = 'FAIL'; recordError('disposable-project-path-transcript-leak-detected'); } - const goalRunnerAllowsProjectCleanup = - !isGoalRuntimeSuite() || - !state.goal.runner.appDataDir || - state.goal.runner.stopped; + const isolatedRunnerAllowsProjectCleanup = + !isIsolatedRunnerSuite() || + !state.isolatedRunner.appDataDir || + state.isolatedRunner.stopped; if ( state.projectRoot && !state.options?.keepProject && - goalRunnerAllowsProjectCleanup + isolatedRunnerAllowsProjectCleanup ) { try { state.cleanupPerformed = await removeDisposableProject(); @@ -476,6 +547,22 @@ try { report = JSON.stringify(summary, null, 2); } } + if (isResponseStreamSuite()) { + state.responseStream.reportLeakCount = countExactSecrets( + Buffer.from(report), + [state.responseStream.finalText, ...responseStreamThinkingMarkers].filter( + isNonEmptyString, + ), + ); + state.evidence.responseStreamReportLeakCount = + state.responseStream.reportLeakCount; + if (state.responseStream.reportLeakCount > 0) { + state.status = 'FAIL'; + recordError('response-stream-private-body-report-leak-detected'); + summary = buildSummary(); + report = JSON.stringify(summary, null, 2); + } + } state.projectPathReportLeakCount = countExactSecrets( Buffer.from(report), disposableProjectPathVariants(), @@ -498,9 +585,25 @@ try { Buffer.from(report), disposableProjectPathVariants(), ); - if (remainingProjectPathReportLeakCount > 0) { + const remainingResponseStreamReportLeakCount = isResponseStreamSuite() + ? countExactSecrets( + Buffer.from(report), + [ + state.responseStream.finalText, + ...responseStreamThinkingMarkers, + ].filter(isNonEmptyString), + ) + : 0; + if ( + remainingProjectPathReportLeakCount > 0 || + remainingResponseStreamReportLeakCount > 0 + ) { state.status = 'FAIL'; - recordError('disposable-project-path-report-redaction-required'); + recordError( + remainingProjectPathReportLeakCount > 0 + ? 'disposable-project-path-report-redaction-required' + : 'response-stream-report-redaction-required', + ); const safeSummary = { status: state.status, suite: state.suite, @@ -511,6 +614,7 @@ try { }, evidence: { projectPathReportLeakCount: remainingProjectPathReportLeakCount, + responseStreamReportLeakCount: remainingResponseStreamReportLeakCount, }, errorCount: state.errors.length, errorHashes: state.errors.map((error) => ({ @@ -593,11 +697,11 @@ async function runRealE2e() { } async function runGoalRuntimeE2e() { - await ensureGoalRunnerStableKillSupport(); + await ensureOwnedRunnerStableKillSupport(); await seedDisposableProject(); await assertGoalInitialMarkerAbsent('goal-project-seeded'); state.cliBinary = await prepareCliBinary(); - await prepareGoalSuiteAppData(); + await prepareIsolatedSuiteAppData(); assertGoalPayloadUnscripted(goalInitialPayload, 'goal-initial'); assertGoalPayloadUnscripted(goalEditedPayload, 'goal-edited'); state.initialTask = { @@ -605,7 +709,7 @@ async function runGoalRuntimeE2e() { sha256: hashValue(goalInitialPayload.outcome), }; - state.goal.runner.launchAttempted = true; + state.isolatedRunner.launchAttempted = true; const started = parseGoalMutation( await runCli( [ @@ -628,7 +732,7 @@ async function runGoalRuntimeE2e() { state.goal.initialRevision = started.goal.revision; state.initialRunId = started.goal.runId; state.initialSessionId = started.goal.sessionId; - await claimGoalRunnerOwnership(); + await claimOwnedRunner(); const canonicalRuntime = await waitForCanonicalRuntime(); assert( canonicalRuntime.agentId === mainAgentId && @@ -747,7 +851,7 @@ async function runGoalRuntimeE2e() { state.goal.newRunnerBootId !== state.goal.oldRunnerBootId, 'goal-runner-boot-did-not-change', ); - await claimGoalRunnerOwnership(restartedRunner); + await claimOwnedRunner(restartedRunner); state.goal.executionOwnerRecovered = await waitForGoalExecutionOwnerTakeover(); await assertGoalRemainsPausedAfterRestart( @@ -784,6 +888,48 @@ async function runGoalRuntimeE2e() { assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); } +async function runResponseStreamE2e() { + await ensureOwnedRunnerStableKillSupport(); + await seedDisposableProject(); + state.cliBinary = await prepareCliBinary(); + await prepareIsolatedSuiteAppData({ streamAgentId: mainAgentId }); + + const task = buildResponseStreamTaskPrompt(); + assertResponseStreamTaskPrompt(task); + state.initialTask = { + chars: [...task].length, + sha256: hashValue(task), + }; + state.initialRunId = requestedRunId; + state.initialSessionId = goalSessionId; + state.isolatedRunner.launchAttempted = true; + await runCli( + [ + '--agent-enqueue', + '--init', + state.projectRoot, + mainAgentId, + requestedRunId, + task, + ], + { timeoutMs: 120_000 }, + ); + await claimOwnedRunner(); + + const canonicalRuntime = await waitForResponseRuntimeIdentity(); + assert( + canonicalRuntime.agentId === mainAgentId && + canonicalRuntime.runId === state.initialRunId && + canonicalRuntime.sessionId === state.initialSessionId, + 'response-stream-runtime-identity-invalid', + ); + state.identityStable = true; + + await observeResponseStreamUntilCommitted(); + state.evidence = await validateResponseStreamEvidence(); + assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); +} + async function runProcessSessionE2e() { await seedProcessSessionDisposableProject(); state.cliBinary = await prepareCliBinary(); @@ -844,6 +990,7 @@ function parseArguments(args) { suite === 'full' || suite === 'llm-runtime' || suite === goalRuntimeSuite || + suite === responseStreamSuite || processSessionSuites.has(suite), 'unsupported-suite', ); @@ -912,6 +1059,60 @@ function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function effectiveAgentLlmConfig(config, agentId) { + const globalConfig = isPlainObject(config.llm) ? config.llm : {}; + const agentConfig = isPlainObject(config.agentLlm?.[agentId]) + ? config.agentLlm[agentId] + : {}; + const value = (key, fallback) => + agentConfig[key] ?? globalConfig[key] ?? fallback; + return { + apiKey: value('apiKey', ''), + baseUrl: value('baseUrl', 'https://api.openai.com/v1'), + model: value('model', 'gpt-4.1'), + apiKind: value('apiKind', 'openai_responses'), + reasoningEffort: value('reasoningEffort', 'high'), + stream: value('stream', false), + requestTimeoutMs: value('requestTimeoutMs', 180_000), + maxRetries: value('maxRetries', 0), + retryBackoffMs: value('retryBackoffMs', 500), + }; +} + +function sameEffectiveAgentLlmWithoutStream(left, right) { + return [ + 'apiKey', + 'baseUrl', + 'model', + 'apiKind', + 'reasoningEffort', + 'requestTimeoutMs', + 'maxRetries', + 'retryBackoffMs', + ].every((key) => left[key] === right[key]); +} + +function isolatedSuiteAppDataProfile() { + if (isGoalRuntimeSuite()) { + return { + prefix: '.agent-runtime-real-e2e-goal-', + sentinelName: goalAppDataSentinelFileName, + sentinelSchema: goalAppDataSentinelSchema, + codePrefix: 'goal-appdata', + }; + } + assert( + isResponseStreamSuite(), + 'isolated-appdata-used-outside-isolated-suite', + ); + return { + prefix: '.agent-runtime-real-e2e-response-stream-', + sentinelName: responseStreamAppDataSentinelFileName, + sentinelSchema: responseStreamAppDataSentinelSchema, + codePrefix: 'response-stream-appdata', + }; +} + async function createSentinelOwnedTempDirectory({ prefix, sentinelName, @@ -940,27 +1141,65 @@ async function createSentinelOwnedTempDirectory({ } } -async function prepareGoalSuiteAppData() { - assert(isGoalRuntimeSuite(), 'goal-appdata-used-outside-goal-suite'); +async function captureSourceRunnerEndpointSnapshot(sourceConfigDir) { + const endpointPath = path.join(sourceConfigDir, runnerEndpointFileName); + const metadata = await fs.lstat(endpointPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) return { exists: false, fingerprint: null }; + assert( + metadata.isFile() && !metadata.isSymbolicLink(), + 'source-runner-endpoint-not-regular-file', + ); + const endpoint = await readJson(endpointPath); + const stableEndpoint = { ...endpoint }; + delete stableEndpoint.heartbeatAt; + return { + exists: true, + fingerprint: hashValue(JSON.stringify(stableEndpoint)), + }; +} + +async function verifySourceRunnerEndpointUnchanged() { + const sourceConfigDir = await fs.realpath(state.options.configDir); + const current = await captureSourceRunnerEndpointSnapshot(sourceConfigDir); + assert( + JSON.stringify(current) === + JSON.stringify(state.isolatedRunner.sourceEndpointSnapshot), + 'source-runner-endpoint-changed-during-isolated-suite', + ); + state.isolatedRunner.sourceRunnerEndpointUnchanged = true; +} + +async function prepareIsolatedSuiteAppData({ streamAgentId = null } = {}) { + assert( + isIsolatedRunnerSuite(), + 'isolated-appdata-used-outside-isolated-suite', + ); + const profile = isolatedSuiteAppDataProfile(); const suiteSecrets = new Set(state.secrets); const sourceConfigDir = await fs.realpath(state.options.configDir); + state.isolatedRunner.sourceEndpointSnapshot = + await captureSourceRunnerEndpointSnapshot(sourceConfigDir); const ownerToken = randomUUID(); const createdAt = Date.now(); const appDataDir = await createSentinelOwnedTempDirectory({ - prefix: path.join(sourceConfigDir, '.agent-runtime-real-e2e-goal-'), - sentinelName: goalAppDataSentinelFileName, + prefix: path.join(sourceConfigDir, profile.prefix), + sentinelName: profile.sentinelName, sentinel: { - schemaVersion: goalAppDataSentinelSchema, + schemaVersion: profile.sentinelSchema, token: ownerToken, ownerPid: process.pid, createdAt, }, - codePrefix: 'goal-appdata', + codePrefix: profile.codePrefix, }); - state.goal.runner.appDataDir = appDataDir; - state.goal.runner.ownerToken = ownerToken; - state.goal.runner.createdAt = createdAt; + state.isolatedRunner.appDataDir = appDataDir; + state.isolatedRunner.ownerToken = ownerToken; + state.isolatedRunner.createdAt = createdAt; + const sourceConfigs = []; for (const name of [configFileName, localConfigFileName]) { const sourcePath = path.join(sourceConfigDir, name); const metadata = await fs.lstat(sourcePath).catch((error) => { @@ -968,51 +1207,133 @@ async function prepareGoalSuiteAppData() { throw error; }); if (!metadata) { - assert(name !== configFileName, 'goal-source-config-missing'); + assert( + name !== configFileName, + `${profile.codePrefix}-source-config-missing`, + ); continue; } assert( metadata.isFile() && !metadata.isSymbolicLink(), - 'goal-source-config-not-regular-file', + `${profile.codePrefix}-source-config-not-regular-file`, ); - const linkedPath = path.join(appDataDir, name); - try { - // Share the private config inode without serializing credentials into a copy. - await fs.link(sourcePath, linkedPath); - } catch (error) { - throw codedError('goal-appdata-config-hardlink-failed', error); - } - const linkedMetadata = await fs.lstat(linkedPath); const sourceContent = await fs.readFile(sourcePath); let sourceConfig; try { sourceConfig = JSON.parse( - decodeUtf8Fatal(sourceContent, 'goal-linked-config-invalid-utf8'), + decodeUtf8Fatal( + sourceContent, + `${profile.codePrefix}-linked-config-invalid-utf8`, + ), ); } catch (error) { - throw codedError('goal-linked-config-json-invalid', error); + throw codedError( + `${profile.codePrefix}-linked-config-json-invalid`, + error, + ); } + assert( + isPlainObject(sourceConfig), + `${profile.codePrefix}-linked-config-root-invalid`, + ); for (const secret of collectApiKeys(sourceConfig)) suiteSecrets.add(secret); + sourceConfigs.push({ + name, + sourcePath, + metadata, + sourceContent, + config: sourceConfig, + }); + } + + const mergedSourceConfig = {}; + for (const source of sourceConfigs) { + mergeConfigPatch(mergedSourceConfig, source.config); + } + const sourceEffective = streamAgentId + ? effectiveAgentLlmConfig(mergedSourceConfig, streamAgentId) + : null; + let activeConfigSource = null; + if (streamAgentId && sourceEffective.stream !== true) { + activeConfigSource = + sourceConfigs.find( + (source) => + source.name === configFileName && + sameEffectiveAgentLlmWithoutStream( + effectiveAgentLlmConfig(source.config, streamAgentId), + sourceEffective, + ), + ) ?? + sourceConfigs.find((source) => + sameEffectiveAgentLlmWithoutStream( + effectiveAgentLlmConfig(source.config, streamAgentId), + sourceEffective, + ), + ); + assert( + Boolean(activeConfigSource), + 'response-stream-source-config-cannot-accept-stream-only-overlay', + ); + } + + for (const source of sourceConfigs) { + const linkedName = activeConfigSource + ? source === activeConfigSource + ? configFileName + : `.source-${source.name}` + : source.name; + const linkedPath = path.join(appDataDir, linkedName); + try { + // Share credential-bearing config inodes without serializing their values. + await fs.link(source.sourcePath, linkedPath); + } catch (error) { + throw codedError(`${profile.codePrefix}-config-hardlink-failed`, error); + } + const linkedMetadata = await fs.lstat(linkedPath); assert( linkedMetadata.isFile() && !linkedMetadata.isSymbolicLink() && - linkedMetadata.dev === metadata.dev && - linkedMetadata.ino === metadata.ino, - 'goal-appdata-config-hardlink-identity-invalid', + linkedMetadata.dev === source.metadata.dev && + linkedMetadata.ino === source.metadata.ino, + `${profile.codePrefix}-config-hardlink-identity-invalid`, ); - state.goal.runner.configLinks.push({ - name, - sourcePath, + state.isolatedRunner.configLinks.push({ + sourceName: source.name, + linkedName, + sourcePath: source.sourcePath, linkedPath, - dev: metadata.dev, - ino: metadata.ino, - sha256: createHash('sha256').update(sourceContent).digest('hex'), + dev: source.metadata.dev, + ino: source.metadata.ino, + nlink: source.metadata.nlink, + sha256: createHash('sha256').update(source.sourceContent).digest('hex'), }); } assert( - state.goal.runner.configLinks.some((link) => link.name === configFileName), - 'goal-appdata-primary-config-link-missing', + state.isolatedRunner.configLinks.some( + (link) => link.linkedName === configFileName, + ), + `${profile.codePrefix}-primary-config-link-missing`, ); + + if (activeConfigSource) { + const overlay = { agentLlm: { [streamAgentId]: { stream: true } } }; + assert( + collectApiKeys(overlay).length === 0 && + JSON.stringify(Object.keys(overlay)) === JSON.stringify(['agentLlm']) && + JSON.stringify(Object.keys(overlay.agentLlm)) === + JSON.stringify([streamAgentId]) && + JSON.stringify(Object.keys(overlay.agentLlm[streamAgentId])) === + JSON.stringify(['stream']), + 'response-stream-overlay-shape-invalid', + ); + await fs.writeFile( + path.join(appDataDir, localConfigFileName), + `${JSON.stringify(overlay)}\n`, + { flag: 'wx', mode: 0o600 }, + ); + state.isolatedRunner.streamOverrideCreated = true; + } + const previousLeakCount = state.transcriptScanner?.count ?? 0; state.secrets = [...suiteSecrets]; state.transcriptScanner = new StreamingSecretScanner(state.secrets); @@ -1023,37 +1344,52 @@ async function prepareGoalSuiteAppData() { if (error?.code === 'ENOENT') return null; throw error; }); - assert(!unexpectedEndpoint, 'goal-appdata-endpoint-preexisted'); + assert(!unexpectedEndpoint, `${profile.codePrefix}-endpoint-preexisted`); state.runtimeConfigDir = appDataDir; + if (streamAgentId) { + const isolatedConfig = await loadConfig(appDataDir); + const isolatedEffective = effectiveAgentLlmConfig( + isolatedConfig.config, + streamAgentId, + ); + assert( + isolatedEffective.stream === true && + ['apiKey', 'baseUrl', 'model'].every( + (key) => + typeof isolatedEffective[key] === 'string' && + isolatedEffective[key].trim().length > 0, + ), + 'response-stream-effective-llm-config-invalid', + ); + state.responseStream.effectiveStreamEnabled = true; + } } -async function readGoalAppDataSentinel() { - const runner = state.goal.runner; +async function readIsolatedAppDataSentinel() { + const runner = state.isolatedRunner; + const profile = isolatedSuiteAppDataProfile(); assert( isNonEmptyString(runner.appDataDir) && isNonEmptyString(runner.ownerToken), - 'goal-appdata-ownership-missing', - ); - const sentinelPath = path.join( - runner.appDataDir, - goalAppDataSentinelFileName, + 'isolated-appdata-ownership-missing', ); + const sentinelPath = path.join(runner.appDataDir, profile.sentinelName); const metadata = await fs.lstat(sentinelPath); const sentinel = await readJson(sentinelPath); assert( metadata.isFile() && !metadata.isSymbolicLink() && - sentinel.schemaVersion === goalAppDataSentinelSchema && + sentinel.schemaVersion === profile.sentinelSchema && sentinel.token === runner.ownerToken && sentinel.ownerPid === process.pid && sentinel.createdAt === runner.createdAt, - 'goal-appdata-ownership-invalid', + 'isolated-appdata-ownership-invalid', ); return sentinel; } -async function inspectGoalRunnerIdentity(status) { - await readGoalAppDataSentinel(); - const runner = state.goal.runner; +async function inspectOwnedRunnerIdentity(status) { + await readIsolatedAppDataSentinel(); + const runner = state.isolatedRunner; const pid = Number(status?.pid ?? status?.status?.pid); const bootId = runnerBootId(status); assert( @@ -1062,7 +1398,7 @@ async function inspectGoalRunnerIdentity(status) { pid > 1 && pid !== process.pid && isNonEmptyString(bootId), - 'goal-owned-runner-status-invalid', + 'isolated-owned-runner-status-invalid', ); const endpointPath = path.join(runner.appDataDir, runnerEndpointFileName); const endpointMetadata = await fs.lstat(endpointPath); @@ -1078,21 +1414,21 @@ async function inspectGoalRunnerIdentity(status) { endpoint.heartbeatAt >= runner.createdAt && isNonEmptyString(endpoint.token) && endpoint.token.length >= 32, - 'goal-owned-runner-endpoint-identity-invalid', + 'isolated-owned-runner-endpoint-identity-invalid', ); return { pid, bootId, protocolVersion: endpoint.protocolVersion, port: endpoint.port, - processIdentity: await captureGoalRunnerProcessIdentity(pid), + processIdentity: await captureOwnedRunnerProcessIdentity(pid), }; } -async function claimGoalRunnerOwnership(status = null) { +async function claimOwnedRunner(status = null) { const liveStatus = status ?? (await readRunnerStatus()); - const identity = await inspectGoalRunnerIdentity(liveStatus); - const current = state.goal.runner.current; + const identity = await inspectOwnedRunnerIdentity(liveStatus); + const current = state.isolatedRunner.current; if (current) { assert( current.pid === identity.pid && @@ -1100,14 +1436,16 @@ async function claimGoalRunnerOwnership(status = null) { current.processIdentity.fingerprint === identity.processIdentity.fingerprint && current.killHandle?.closed === false, - 'goal-owned-runner-identity-changed-after-claim', + 'isolated-owned-runner-identity-changed-after-claim', ); return current; } - const killHandle = await openGoalRunnerKillHandle(identity.pid); + const killHandle = await openOwnedRunnerKillHandle(identity.pid); try { - const rechecked = await inspectGoalRunnerIdentity(await readRunnerStatus()); + const rechecked = await inspectOwnedRunnerIdentity( + await readRunnerStatus(), + ); assert( rechecked.pid === identity.pid && rechecked.bootId === identity.bootId && @@ -1115,21 +1453,21 @@ async function claimGoalRunnerOwnership(status = null) { rechecked.port === identity.port && rechecked.processIdentity.fingerprint === identity.processIdentity.fingerprint, - 'goal-owned-runner-identity-changed-during-pidfd-claim', + 'isolated-owned-runner-identity-changed-during-pidfd-claim', ); } catch (error) { - await closeGoalRunnerKillHandle(killHandle).catch(() => {}); + await closeOwnedRunnerKillHandle(killHandle).catch(() => {}); throw error; } - state.goal.runner.current = { ...identity, killHandle }; - state.goal.runner.pidfdClaimCount += 1; - return state.goal.runner.current; + state.isolatedRunner.current = { ...identity, killHandle }; + state.isolatedRunner.pidfdClaimCount += 1; + return state.isolatedRunner.current; } -async function verifyOwnedGoalRunnerForKill() { - const claimed = state.goal.runner.current; - assert(claimed, 'goal-owned-runner-not-claimed'); - const current = await inspectGoalRunnerIdentity(await readRunnerStatus()); +async function verifyOwnedRunnerForKill() { + const claimed = state.isolatedRunner.current; + assert(claimed, 'isolated-owned-runner-not-claimed'); + const current = await inspectOwnedRunnerIdentity(await readRunnerStatus()); assert( current.pid === claimed.pid && current.bootId === claimed.bootId && @@ -1139,15 +1477,15 @@ async function verifyOwnedGoalRunnerForKill() { claimed.processIdentity.fingerprint && claimed.killHandle?.pid === claimed.pid && claimed.killHandle.closed === false, - 'goal-owned-runner-identity-changed-before-kill', + 'isolated-owned-runner-identity-changed-before-kill', ); return claimed; } -async function ensureGoalRunnerStableKillSupport() { +async function ensureOwnedRunnerStableKillSupport() { assert( process.platform === 'linux', - 'goal-runner-stable-kill-handle-platform-unsupported', + 'isolated-runner-stable-kill-handle-platform-unsupported', ); const python = await findControlledLinuxPython(); const probe = @@ -1159,7 +1497,7 @@ async function ensureGoalRunnerStableKillSupport() { env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, }); } catch (error) { - throw codedError('goal-runner-pidfd-support-unavailable', error); + throw codedError('isolated-runner-pidfd-support-unavailable', error); } } @@ -1175,13 +1513,13 @@ async function findControlledLinuxPython() { return resolved; } } - throw codedError('goal-runner-controlled-python-unavailable'); + throw codedError('isolated-runner-controlled-python-unavailable'); } -async function openGoalRunnerKillHandle(pid) { +async function openOwnedRunnerKillHandle(pid) { assert( process.platform === 'linux' && Number.isSafeInteger(pid) && pid > 1, - 'goal-runner-pidfd-open-precondition-invalid', + 'isolated-runner-pidfd-open-precondition-invalid', ); const python = await findControlledLinuxPython(); const child = spawn( @@ -1206,15 +1544,15 @@ async function openGoalRunnerKillHandle(pid) { child.stderr.on('data', (chunk) => { handle.stderr = appendBounded(handle.stderr, chunk, 4_096); }); - await waitForGoalRunnerKillHandleReady(handle); + await waitForOwnedRunnerKillHandleReady(handle); return handle; } -async function waitForGoalRunnerKillHandleReady(handle) { +async function waitForOwnedRunnerKillHandleReady(handle) { await new Promise((resolve, reject) => { const timer = setTimeout(() => { handle.child.kill('SIGKILL'); - reject(codedError('goal-runner-pidfd-open-timeout')); + reject(codedError('isolated-runner-pidfd-open-timeout')); }, 10_000); const settle = (callback) => { clearTimeout(timer); @@ -1230,10 +1568,10 @@ async function waitForGoalRunnerKillHandleReady(handle) { }; const onError = (error) => settle(() => - reject(codedError('goal-runner-pidfd-helper-spawn-failed', error)), + reject(codedError('isolated-runner-pidfd-helper-spawn-failed', error)), ); const onClose = () => - settle(() => reject(codedError('goal-runner-pidfd-open-failed'))); + settle(() => reject(codedError('isolated-runner-pidfd-open-failed'))); handle.child.stdout.on('data', onData); handle.child.on('error', onError); handle.child.on('close', onClose); @@ -1241,30 +1579,30 @@ async function waitForGoalRunnerKillHandleReady(handle) { }); } -async function closeGoalRunnerKillHandle(handle) { +async function closeOwnedRunnerKillHandle(handle) { if (!handle || handle.closed) return; handle.closed = true; if (handle.child.exitCode !== null || handle.child.signalCode !== null) return; handle.child.stdin.end('CLOSE\n'); const result = await waitForChildClose(handle.child, 10_000); - assert(result.code === 0, 'goal-runner-pidfd-close-failed'); + assert(result.code === 0, 'isolated-runner-pidfd-close-failed'); } -async function signalGoalRunnerKillHandle(handle) { +async function signalOwnedRunnerKillHandle(handle) { assert( handle && handle.closed === false && handle.child.exitCode === null && handle.child.signalCode === null, - 'goal-runner-pidfd-handle-not-live', + 'isolated-runner-pidfd-handle-not-live', ); handle.closed = true; handle.child.stdin.end('KILL\n'); const result = await waitForChildClose(handle.child, 15_000); assert( result.code === 0 && handle.stdout.includes(Buffer.from('PIDFD_EXITED\n')), - 'goal-runner-pidfd-sigkill-failed', + 'isolated-runner-pidfd-sigkill-failed', ); } @@ -1275,12 +1613,12 @@ async function waitForChildClose(child, timeoutMs) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); - reject(codedError('goal-runner-pidfd-helper-timeout')); + reject(codedError('isolated-runner-pidfd-helper-timeout')); }, timeoutMs); const onError = (error) => { clearTimeout(timer); child.off('close', onClose); - reject(codedError('goal-runner-pidfd-helper-failed', error)); + reject(codedError('isolated-runner-pidfd-helper-failed', error)); }; const onClose = (code, signal) => { clearTimeout(timer); @@ -1292,11 +1630,11 @@ async function waitForChildClose(child, timeoutMs) { }); } -async function captureGoalRunnerProcessIdentity(pid) { - const expectedAppData = state.goal.runner.appDataDir; +async function captureOwnedRunnerProcessIdentity(pid) { + const expectedAppData = state.isolatedRunner.appDataDir; assert( isNonEmptyString(expectedAppData) && Boolean(state.cliBinary), - 'goal-runner-process-identity-context-missing', + 'isolated-runner-process-identity-context-missing', ); if (process.platform === 'linux') { const [executable, expectedExecutable, stat, commandLine] = @@ -1321,7 +1659,7 @@ async function captureGoalRunnerProcessIdentity(pid) { argv.includes('--agent-runner') && configIndex >= 0 && argv[configIndex + 1] === expectedAppData, - 'goal-runner-linux-process-identity-invalid', + 'isolated-runner-linux-process-identity-invalid', ); return { kind: 'linux-proc', @@ -1339,7 +1677,7 @@ async function captureGoalRunnerProcessIdentity(pid) { result.stdout.includes(path.basename(state.cliBinary)) && result.stdout.includes('--agent-runner') && result.stdout.includes(expectedAppData), - 'goal-runner-darwin-process-identity-invalid', + 'isolated-runner-darwin-process-identity-invalid', ); return { kind: 'darwin-ps', @@ -1364,7 +1702,7 @@ async function captureGoalRunnerProcessIdentity(pid) { isNonEmptyString(value.CreationDate) && commandLine.includes('--agent-runner') && commandLine.includes(expectedAppData), - 'goal-runner-windows-process-identity-invalid', + 'isolated-runner-windows-process-identity-invalid', ); return { kind: 'windows-cim', @@ -1379,7 +1717,7 @@ async function captureGoalRunnerProcessIdentity(pid) { }; } - throw codedError('goal-runner-process-identity-platform-unsupported'); + throw codedError('isolated-runner-process-identity-platform-unsupported'); } function isProcessAlive(pid) { @@ -1394,19 +1732,19 @@ function isProcessAlive(pid) { } async function killRunnerPidOnce(pid, ownedRunner) { - if (isGoalRuntimeSuite()) { - assert(ownedRunner, 'goal-runner-pid-kill-fallback-forbidden'); + if (isIsolatedRunnerSuite()) { + assert(ownedRunner, 'isolated-runner-pid-kill-fallback-forbidden'); } if (ownedRunner) { - const claimed = state.goal.runner.current; + const claimed = state.isolatedRunner.current; assert( claimed?.pid === pid && claimed.killHandle?.pid === pid, - 'goal-runner-pidfd-identity-missing', + 'isolated-runner-pidfd-identity-missing', ); - await signalGoalRunnerKillHandle(claimed.killHandle); - state.goal.runner.pidfdSignalCount += 1; + await signalOwnedRunnerKillHandle(claimed.killHandle); + state.isolatedRunner.pidfdSignalCount += 1; state.runnerKilled = true; - state.goal.runner.current = null; + state.isolatedRunner.current = null; return; } try { @@ -1418,7 +1756,7 @@ async function killRunnerPidOnce(pid, ownedRunner) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { if (!isProcessAlive(pid)) { - if (ownedRunner) state.goal.runner.current = null; + if (ownedRunner) state.isolatedRunner.current = null; return; } await sleep(50); @@ -1426,36 +1764,36 @@ async function killRunnerPidOnce(pid, ownedRunner) { throw codedError('runner-still-alive-after-sigkill'); } -async function stopClaimedGoalRunnerWithoutEndpoint() { - const claimed = state.goal.runner.current; +async function stopClaimedOwnedRunnerWithoutEndpoint() { + const claimed = state.isolatedRunner.current; if (!claimed) return; if (!isProcessAlive(claimed.pid)) { - await closeGoalRunnerKillHandle(claimed.killHandle); - state.goal.runner.current = null; + await closeOwnedRunnerKillHandle(claimed.killHandle); + state.isolatedRunner.current = null; return; } let currentIdentity; try { - currentIdentity = await captureGoalRunnerProcessIdentity(claimed.pid); + currentIdentity = await captureOwnedRunnerProcessIdentity(claimed.pid); } catch (error) { if (!isProcessAlive(claimed.pid)) { - await closeGoalRunnerKillHandle(claimed.killHandle); - state.goal.runner.current = null; + await closeOwnedRunnerKillHandle(claimed.killHandle); + state.isolatedRunner.current = null; return; } throw error; } assert( currentIdentity.fingerprint === claimed.processIdentity.fingerprint, - 'goal-owned-runner-identity-changed-without-endpoint', + 'isolated-owned-runner-identity-changed-without-endpoint', ); await killRunnerPidOnce(claimed.pid, true); } -async function stopOwnedGoalRunner() { - await readGoalAppDataSentinel(); +async function stopOwnedIsolatedRunner() { + await readIsolatedAppDataSentinel(); const endpointPath = path.join( - state.goal.runner.appDataDir, + state.isolatedRunner.appDataDir, runnerEndpointFileName, ); const endpointMetadata = await fs.lstat(endpointPath).catch((error) => { @@ -1464,33 +1802,33 @@ async function stopOwnedGoalRunner() { }); if (!endpointMetadata) { assert( - state.goal.runner.current || !state.goal.runner.launchAttempted, - 'goal-owned-runner-endpoint-missing-before-stable-claim', + state.isolatedRunner.current || !state.isolatedRunner.launchAttempted, + 'isolated-owned-runner-endpoint-missing-before-stable-claim', ); - await stopClaimedGoalRunnerWithoutEndpoint(); + await stopClaimedOwnedRunnerWithoutEndpoint(); return; } assert( endpointMetadata.isFile() && !endpointMetadata.isSymbolicLink(), - 'goal-owned-runner-endpoint-not-regular-file', + 'isolated-owned-runner-endpoint-not-regular-file', ); const endpoint = await readJson(endpointPath); const status = await readRunnerStatus(); if (status?.running !== true) { assert( !isProcessAlive(Number(endpoint.pid)), - 'goal-owned-runner-live-pid-without-identity', + 'isolated-owned-runner-live-pid-without-identity', ); - await closeGoalRunnerKillHandle(state.goal.runner.current?.killHandle); - state.goal.runner.current = null; + await closeOwnedRunnerKillHandle(state.isolatedRunner.current?.killHandle); + state.isolatedRunner.current = null; return; } - await claimGoalRunnerOwnership(status); + await claimOwnedRunner(status); await killRunnerOnce(); } -async function verifyGoalSuiteConfigLinksUnchanged() { - for (const link of state.goal.runner.configLinks) { +async function verifyIsolatedSuiteConfigLinksUnchanged() { + for (const link of state.isolatedRunner.configLinks) { const [sourceMetadata, linkedMetadata, sourceContent] = await Promise.all([ fs.lstat(link.sourcePath), fs.lstat(link.linkedPath), @@ -1507,45 +1845,62 @@ async function verifyGoalSuiteConfigLinksUnchanged() { linkedMetadata.ino === link.ino && createHash('sha256').update(sourceContent).digest('hex') === link.sha256, - 'goal-source-config-changed-during-suite', + 'isolated-source-config-changed-during-suite', ); } } -async function removeGoalSuiteAppData() { - await readGoalAppDataSentinel(); +async function verifySourceConfigLinkCountsRestored() { + for (const link of state.isolatedRunner.configLinks) { + const metadata = await fs.lstat(link.sourcePath); + assert( + metadata.isFile() && + !metadata.isSymbolicLink() && + metadata.dev === link.dev && + metadata.ino === link.ino && + metadata.nlink === link.nlink, + 'isolated-source-config-link-count-not-restored', + ); + } +} + +async function removeIsolatedSuiteAppData() { + await readIsolatedAppDataSentinel(); + const profile = isolatedSuiteAppDataProfile(); const [sourceConfigDir, appDataDir] = await Promise.all([ fs.realpath(state.options.configDir), - fs.realpath(state.goal.runner.appDataDir), + fs.realpath(state.isolatedRunner.appDataDir), ]); assert( isPathInside(sourceConfigDir, appDataDir) && - path.basename(appDataDir).startsWith('.agent-runtime-real-e2e-goal-'), - 'goal-appdata-cleanup-path-invalid', + path.basename(appDataDir).startsWith(profile.prefix), + 'isolated-appdata-cleanup-path-invalid', ); - let linkError = null; + let ownershipError = null; try { - await verifyGoalSuiteConfigLinksUnchanged(); + await verifyIsolatedSuiteConfigLinksUnchanged(); + await verifySourceRunnerEndpointUnchanged(); } catch (error) { - linkError = error; + ownershipError = error; } await fs.rm(appDataDir, { recursive: true, force: false }); state.runtimeConfigDir = state.options.configDir; - if (linkError) throw linkError; + try { + await verifySourceConfigLinkCountsRestored(); + state.isolatedRunner.sourceConfigLinksVerified = true; + } catch (error) { + ownershipError ??= error; + } + if (ownershipError) throw ownershipError; return true; } async function checkPrerequisites(config) { - const requiredAgents = isGoalRuntimeSuite() + const requiredAgents = isIsolatedRunnerSuite() ? [mainAgentId] : [mainAgentId, 'quality-review']; const llmConfigured = requiredAgents.every((agentId) => { - const effective = { - apiKey: config.llm?.apiKey, - baseUrl: config.llm?.baseUrl ?? 'https://api.openai.com/v1', - model: config.llm?.model ?? 'gpt-4.1', - ...(config.agentLlm?.[agentId] ?? {}), - }; + const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( (key) => typeof effective[key] === 'string' && effective[key].trim().length > 0, @@ -1558,7 +1913,9 @@ async function checkPrerequisites(config) { ); return { llmConfigured, - chromeAvailable: Boolean(await findSupportedBrowser()), + chromeAvailable: isIsolatedRunnerSuite() + ? false + : Boolean(await findSupportedBrowser()), editorApiConfigured, }; } @@ -1667,7 +2024,13 @@ async function seedDisposableProject() { const lureB = `LURE_CONFIG_${randomUUID().replaceAll('-', '')}`; const lureC = `LURE_PRIVATE_${randomUUID().replaceAll('-', '')}`; const lureD = `LURE_GIT_${randomUUID().replaceAll('-', '')}`; - state.lures = [lureA, lureB, lureC, lureD]; + state.lures = [ + lureA, + lureB, + lureC, + lureD, + ...(isResponseStreamSuite() ? [responseStreamThinkingCanary] : []), + ]; await Promise.all([ fs.writeFile( @@ -1687,7 +2050,7 @@ async function seedDisposableProject() { ), fs.writeFile( path.join(state.projectRoot, 'verify-e2e.mjs'), - isGoalRuntimeSuite() + isGoalRuntimeSuite() || isResponseStreamSuite() ? goalRevisionOneVerificationFixtureSource() : goalRevisionTwoVerificationFixtureSource(), ), @@ -1711,7 +2074,11 @@ async function seedDisposableProject() { ), fs.writeFile( path.join(state.projectRoot, '.agent/private-secret.txt'), - `${lureC}\n`, + `${lureC}\n${ + isResponseStreamSuite() + ? `${responseStreamThinkingCanary}\n` + : '' + }`, { mode: 0o600 }, ), fs.mkdir(path.join(state.projectRoot, 'data'), { recursive: true }), @@ -1994,6 +2361,24 @@ function buildProcessSessionTaskPrompt() { return `交付当前 disposable 项目的真实交互服务验收结果。有效证据必须同时证明:项目清单中的唯一交互服务只有一个实例;同一会话观察到 readiness;服务给出的一次性 challenge 被原样作为单独一行交互一次并得到精确回显;服务形成可信终态;项目文件保持不变。不得为探测、试错、重试或停止另起实例,不得在最终回复中复述 challenge、回显或其他私有进程输出。`; } +function buildResponseStreamTaskPrompt() { + return `只读审阅当前 disposable 项目的现有仓库事实与可用验收结果,向开发者给出一份完整、明确、自然的中文判断。最终回复应分别说明结论、可信依据和仍需留意的边界,每部分都要有实际内容;不要修改项目,不要虚构未观察到的事实,也不要读取或转述敏感诱饵、配置密钥、Runtime 私有正文或项目绝对路径。`; +} + +function assertResponseStreamTaskPrompt(task) { + assertResultOrientedDisposableTask(task, 'response-stream-task'); + for (const forbidden of [ + responseStreamThinkingCanary, + ...responseStreamThinkingMarkers.slice(0, 2), + 'response-streams', + 'requestSlot', + 'sequence', + 'accumulatedText', + ]) { + assert(!task.includes(forbidden), 'response-stream-task-recipe-leak'); + } +} + function assertProcessSessionTaskPrompt(task) { assertResultOrientedDisposableTask(task, 'process-session-task'); for (const forbidden of [ @@ -2172,6 +2557,14 @@ async function prepareCliBinary() { async function runCli(args, options = {}) { assert(Boolean(state.cliBinary), 'cli-binary-not-ready'); assert(Boolean(state.runtimeConfigDir), 'runtime-config-dir-not-ready'); + if ( + isIsolatedRunnerSuite() && + state.options?.configDir && + path.resolve(state.runtimeConfigDir) === + path.resolve(state.options.configDir) + ) { + state.isolatedRunner.sourceConfigCliCallCount += 1; + } return runProcess( state.cliBinary, [...args, '--config-dir', state.runtimeConfigDir], @@ -2290,7 +2683,7 @@ async function readRunnerStatus() { return parseAssignedJson(result.stdout, ['runnerJson']); } -async function waitForCanonicalRuntime() { +async function waitForCanonicalRuntime({ allowTerminal = false } = {}) { const deadline = Date.now() + 120_000; while (Date.now() < deadline) { const runtime = await readRuntime(mainAgentId).catch(() => null); @@ -2300,7 +2693,7 @@ async function waitForCanonicalRuntime() { runtime.runId.length > 0 && typeof runtime.sessionId === 'string' && runtime.sessionId.length > 0 && - !isTerminalRuntime(runtime) + (allowTerminal || !isTerminalRuntime(runtime)) ) { return runtime; } @@ -2310,8 +2703,8 @@ async function waitForCanonicalRuntime() { } async function killRunnerOnce() { - const ownedRunner = isGoalRuntimeSuite() - ? await verifyOwnedGoalRunnerForKill() + const ownedRunner = isIsolatedRunnerSuite() + ? await verifyOwnedRunnerForKill() : null; const runner = ownedRunner ? null : await readRunnerStatus(); const pid = ownedRunner @@ -3505,8 +3898,273 @@ async function driveRuntimeToQuiescence() { throw codedError('runtime-e2e-timeout'); } -async function readGoalRuntimePersistence() { - const taskSnapshot = await readTaskSnapshot(); +function responseStreamSidecarPath() { + assert( + isNonEmptyString(state.initialRunId), + 'response-stream-run-identity-missing', + ); + return path.join( + state.projectRoot, + '.agent/runtime/response-streams', + hashValue(mainAgentId).slice(0, 32), + `${hashValue(state.initialRunId).slice(0, 32)}.json`, + ); +} + +async function waitForResponseRuntimeIdentity() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const runtime = await readJson(mainRuntimeStatePath()).catch(() => null); + if ( + runtime?.agentId === mainAgentId && + isNonEmptyString(runtime.runId) && + isNonEmptyString(runtime.sessionId) + ) { + return runtime; + } + await sleep(50); + } + throw codedError('response-stream-runtime-did-not-start'); +} + +async function readResponseStreamPollSample() { + const [stream, runtime, taskSnapshot, agentDb, conversations] = + await Promise.all([ + readJson(responseStreamSidecarPath()).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }), + readJson(mainRuntimeStatePath()).catch(() => null), + readTaskSnapshot(), + readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), + readOptionalJsonl( + agentConversationPath(mainAgentId, state.initialSessionId), + ), + ]); + const lifecycle = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.requestKind === 'final-reply' && + (!stream || record.requestSlot === stream.requestSlot), + ); + return { + stream, + runtime, + taskSnapshot, + agentDb, + conversations, + lifecycle, + }; +} + +function observeResponseStreamPollSample(sample, pollOrdinal) { + const { stream, runtime, conversations, lifecycle } = sample; + const assistants = conversations.filter( + (message) => message.role === 'assistant', + ); + const lifecycleStarted = lifecycle.filter( + (record) => record.status === 'started', + ); + const lifecycleTerminal = lifecycle.filter((record) => + ['completed', 'failed', 'interrupted'].includes(record.status), + ); + const terminalObserved = + Boolean(stream && stream.status !== 'streaming') || + lifecycleTerminal.length > 0 || + assistants.length > 0 || + Boolean(runtime && isTerminalRuntime(runtime)); + if (terminalObserved && state.responseStream.firstTerminalPoll == null) { + state.responseStream.firstTerminalPoll = pollOrdinal; + } + if (!stream) return; + + assert( + stream.schemaVersion === 'game-creator-runtime-response-stream.v1' && + stream.agentId === mainAgentId && + stream.taskId === runtime?.taskId && + stream.sessionId === state.initialSessionId && + stream.runId === state.initialRunId && + stream.requestKind === 'final-reply' && + isNonEmptyString(stream.requestSlot) && + Number.isSafeInteger(stream.appliedSteerCursor) && + Number.isSafeInteger(stream.responseRevision) && + Number.isSafeInteger(stream.sequence) && + stream.sequence >= 0 && + ['streaming', 'ready', 'committed', 'discarded', 'failed'].includes( + stream.status, + ) && + typeof stream.accumulatedText === 'string' && + [...stream.accumulatedText].length <= 32_000 && + Number.isSafeInteger(stream.startedAt) && + Number.isSafeInteger(stream.updatedAt) && + stream.startedAt > 0 && + stream.updatedAt >= stream.startedAt, + 'response-stream-snapshot-invalid', + ); + if (state.responseStream.finalRequestSlot == null) { + state.responseStream.finalRequestSlot = stream.requestSlot; + state.responseStream.finalResponseRevision = stream.responseRevision; + } else { + assert( + state.responseStream.finalRequestSlot === stream.requestSlot && + state.responseStream.finalResponseRevision === stream.responseRevision, + 'response-stream-identity-changed', + ); + } + + const privateLeakValues = [ + ...state.secrets, + ...state.lures, + ...responseStreamThinkingMarkers, + ...disposableProjectPathVariants(), + ]; + assert( + countExactSecrets( + Buffer.from(stream.accumulatedText), + privateLeakValues, + ) === 0, + 'response-stream-private-sensitive-value-leak', + ); + + const last = state.responseStream.lastSnapshot; + if (last) { + assert( + stream.sequence >= last.sequence, + 'response-stream-sequence-regressed', + ); + if (stream.sequence === last.sequence) { + assert( + stream.status === last.status && + stream.accumulatedText === last.accumulatedText && + stream.finishReason === last.finishReason, + 'response-stream-same-sequence-changed', + ); + } else if (stream.status === 'streaming' && last.status === 'streaming') { + assert( + stream.accumulatedText.startsWith(last.accumulatedText), + 'response-stream-streaming-prefix-regressed', + ); + } + } + + const changed = + !last || + stream.sequence !== last.sequence || + stream.status !== last.status || + stream.accumulatedText !== last.accumulatedText || + stream.finishReason !== last.finishReason; + if (changed) { + const chars = [...stream.accumulatedText].length; + const fingerprint = hashValue(stream.accumulatedText); + const beforeTerminal = + state.responseStream.firstTerminalPoll == null || + pollOrdinal < state.responseStream.firstTerminalPoll; + state.responseStream.observedSnapshots.push({ + sequence: stream.sequence, + status: stream.status, + chars, + fingerprint, + pollOrdinal, + beforeTerminal, + providerStartedCount: lifecycleStarted.length, + providerTerminalCount: lifecycleTerminal.length, + assistantCount: assistants.length, + }); + if (stream.status === 'streaming' && chars > 0) { + assert( + beforeTerminal && + lifecycleStarted.length === 1 && + lifecycleTerminal.length === 0 && + assistants.length === 0 && + runtime?.status === 'running' && + runtime?.phase === 'response', + 'response-stream-nonempty-snapshot-not-before-terminal', + ); + } + } + state.responseStream.lastSnapshot = { + sequence: stream.sequence, + status: stream.status, + accumulatedText: stream.accumulatedText, + finishReason: stream.finishReason, + }; +} + +async function observeResponseStreamUntilCommitted() { + const deadline = Date.now() + runTimeoutMs; + let quietPolls = 0; + let pollOrdinal = 0; + while (Date.now() < deadline) { + pollOrdinal += 1; + state.responseStream.pollCount = pollOrdinal; + const pending = (await findPendingActions()).filter( + (candidate) => + candidate.agentId === mainAgentId && + candidate.runId === state.initialRunId, + ); + if (pending.length > 0) { + assert( + pending.length === 1 && pending[0].tool === 'project.verify', + 'response-stream-unexpected-pending-action', + ); + state.responseStream.confirmedProjectVerifyCount += 1; + assert( + state.responseStream.confirmedProjectVerifyCount <= 1, + 'response-stream-project-verify-confirmation-repeated', + ); + await confirmPendingActions( + new Set(['project.verify']), + (candidate) => + candidate.agentId === mainAgentId && + candidate.runId === state.initialRunId, + ); + await sleep(50); + continue; + } + const sample = await readResponseStreamPollSample(); + observeResponseStreamPollSample(sample, pollOrdinal); + const latest = sample.taskSnapshot.latest.find( + (task) => + task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + if (latest && isFailedTask(latest)) { + throw codedError('response-stream-runtime-failed'); + } + if (sample.runtime?.phase === 'needs-reconciliation') { + throw codedError('response-stream-runtime-needs-reconciliation'); + } + if (['discarded', 'failed'].includes(sample.stream?.status)) { + throw codedError('response-stream-terminal-failure'); + } + const assistants = sample.conversations.filter( + (message) => message.role === 'assistant', + ); + const finalLifecycle = sample.lifecycle.filter((record) => + ['started', 'completed', 'failed', 'interrupted'].includes(record.status), + ); + const completed = + sample.stream?.status === 'committed' && + sample.runtime?.status === 'idle' && + sample.runtime?.phase === 'completed' && + latest?.status === 'completed' && + latest?.phase === 'completed' && + assistants.length === 1 && + JSON.stringify(finalLifecycle.map((record) => record.status)) === + JSON.stringify(['started', 'completed']); + if (completed) { + quietPolls += 1; + if (quietPolls >= 2) return; + } else { + quietPolls = 0; + } + await sleep(50); + } + throw codedError('response-stream-e2e-timeout'); +} + +async function readAllRuntimeEvents() { const eventFiles = await listFiles( path.join(state.projectRoot, '.agent/runtime/events'), ); @@ -3514,6 +4172,553 @@ async function readGoalRuntimePersistence() { for (const file of eventFiles.filter((entry) => entry.endsWith('.jsonl'))) { events.push(...(await readJsonl(file))); } + return events; +} + +async function readResponseStreamPersistence() { + const [ + taskSnapshot, + events, + agentDb, + conversations, + activity, + output, + runtimeState, + stream, + ] = await Promise.all([ + readTaskSnapshot(), + readAllRuntimeEvents(), + readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), + readOptionalJsonl( + agentConversationPath(mainAgentId, state.initialSessionId), + ), + readOptionalJsonl(path.join(state.projectRoot, '.agent/activity.jsonl')), + readOptionalJsonl(path.join(state.projectRoot, '.agent/output.jsonl')), + readJson(mainRuntimeStatePath()), + readJson(responseStreamSidecarPath()), + ]); + return { + taskSnapshot, + events, + agentDb, + conversations, + activity, + output, + runtimeState, + stream, + }; +} + +function countSensitiveValuesBySurface(surfaces, values, codePrefix) { + const counts = {}; + for (const [surface, records] of Object.entries(surfaces)) { + const entries = Array.isArray(records) ? records : [records]; + counts[surface] = countExactSecrets( + Buffer.from(entries.map((record) => JSON.stringify(record)).join('\n')), + values, + ); + assert(counts[surface] === 0, `${codePrefix}-${surface}-leak`); + } + return counts; +} + +function validateResponseStreamProviderLifecycle(agentDb, stream) { + const records = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.requestKind === 'final-reply', + ); + const allowedKeys = new Set([ + 'recordType', + 'auditSchemaVersion', + 'agentId', + 'taskId', + 'sessionId', + 'runId', + 'source', + 'requestId', + 'requestKind', + 'requestSlot', + 'status', + 'schemaVersion', + 'updatedAt', + ]); + assert(records.length === 2, 'response-stream-final-lifecycle-count-invalid'); + for (const record of records) { + assert( + record.auditSchemaVersion === + 'game-creator-provider-request-lifecycle.v1' && + record.taskId === stream.taskId && + record.sessionId === state.initialSessionId && + record.requestKind === 'final-reply' && + record.requestSlot === stream.requestSlot && + isNonEmptyString(record.requestId) && + Number.isSafeInteger(record.updatedAt) && + record.updatedAt > 0 && + Object.keys(record).every((key) => allowedKeys.has(key)), + 'response-stream-final-lifecycle-record-invalid', + ); + } + assert( + records[0].status === 'started' && + records[1].status === 'completed' && + records[0].requestId === records[1].requestId && + records[0].source === records[1].source && + records[1].updatedAt >= records[0].updatedAt && + agentDb.indexOf(records[0]) < agentDb.indexOf(records[1]), + 'response-stream-final-lifecycle-transition-invalid', + ); + return { + requestId: records[0].requestId, + requestSlot: records[0].requestSlot, + startedCount: 1, + terminalCount: 1, + }; +} + +function validateResponseStreamFinalization( + agentDb, + runtimeState, + finalAssistant, +) { + const records = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.finalization.lifecycle' && + record.agentId === mainAgentId && + record.runId === state.initialRunId, + ); + const expectedStages = [ + 'prepared', + 'assistant-persisted', + 'runtime-completed', + 'goal-completed', + ]; + const responseFingerprint = hashValue(finalAssistant.content); + const responseChars = [...finalAssistant.content].length; + const finalizationId = records[0]?.finalizationId; + const messageId = finalAssistant.messageId; + assert( + records.length === expectedStages.length && + isNonEmptyString(finalizationId) && + isNonEmptyString(messageId), + 'response-stream-finalization-count-invalid', + ); + for (const [index, record] of records.entries()) { + assert( + record.auditSchemaVersion === 'game-creator-finalization-lifecycle.v1' && + record.journalSchemaVersion === + 'game-creator-runtime-finalization.v3' && + record.finalizationId === finalizationId && + record.messageId === messageId && + record.taskId === runtimeState.taskId && + record.sessionId === state.initialSessionId && + record.stage === expectedStages[index] && + record.stageOrdinal === index + 1 && + record.previousStage === + (index === 0 ? null : expectedStages[index - 1]) && + record.goalId == null && + record.goalRevision === 0 && + record.responseFingerprint === responseFingerprint && + record.responseChars === responseChars && + isNonEmptyString(record.conversationPath) && + !path.isAbsolute(record.conversationPath) && + Number.isSafeInteger(record.stageAt) && + record.stageAt > 0 && + !['task', 'response', 'prompt', 'observation'].some((key) => + Object.hasOwn(record, key), + ), + 'response-stream-finalization-record-invalid', + ); + if (index > 0) { + assert( + record.stageAt >= records[index - 1].stageAt && + agentDb.indexOf(records[index - 1]) < agentDb.indexOf(record), + 'response-stream-finalization-order-invalid', + ); + } + } + const assistantAuditIndex = agentDb.findIndex( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant' && + record.messageId === messageId && + record.finalizationId === finalizationId, + ); + const assistantStageIndex = agentDb.indexOf(records[1]); + assert( + assistantAuditIndex >= 0 && assistantAuditIndex < assistantStageIndex, + 'response-stream-finalization-assistant-order-invalid', + ); + return { + finalizationId, + messageId, + responseFingerprint, + responseChars, + stageCount: records.length, + assistantAuditIndex, + }; +} + +async function validateResponseStreamEvidence() { + const persistence = await readResponseStreamPersistence(); + const { + taskSnapshot, + events, + agentDb, + conversations, + activity, + output, + runtimeState, + stream, + } = persistence; + assert(taskSnapshot.all.length > 0, 'response-stream-task-evidence-missing'); + assert(events.length > 0, 'response-stream-event-evidence-missing'); + assert(agentDb.length > 0, 'response-stream-agent-db-evidence-missing'); + assert( + state.isolatedRunner.sourceConfigCliCallCount === 0, + 'response-stream-formal-config-cli-call-detected', + ); + assertNoPersistedImagePayload('response-stream-event', events); + assertNoPersistedImagePayload('response-stream-agent-db', agentDb); + + const targetTasks = taskSnapshot.all.filter( + (task) => task.agentId === mainAgentId, + ); + const targetRunIds = [...new Set(targetTasks.map((task) => task.runId))]; + const latest = taskSnapshot.latest.find( + (task) => task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + const finalMessage = finalMessageId( + mainAgentId, + state.initialSessionId, + state.initialRunId, + ); + const userMessages = conversations.filter( + (message) => message.role === 'user', + ); + const assistantMessages = conversations.filter( + (message) => message.role === 'assistant', + ); + const finalAssistant = assistantMessages.find( + (message) => message.messageId === finalMessage, + ); + const finalText = finalAssistant?.content; + const finalTextCharacters = isNonEmptyString(finalText) + ? [...finalText.trim()] + : []; + const runtimeResponsePreview = + finalTextCharacters.slice(0, 500).join('') + + (finalTextCharacters.length > 500 ? '…' : ''); + assert( + JSON.stringify(targetRunIds) === JSON.stringify([state.initialRunId]) && + latest?.status === 'completed' && + latest?.phase === 'completed' && + runtimeState.agentId === mainAgentId && + runtimeState.taskId === latest.taskId && + runtimeState.sessionId === state.initialSessionId && + runtimeState.runId === state.initialRunId && + runtimeState.status === 'idle' && + runtimeState.phase === 'completed' && + userMessages.length === 1 && + assistantMessages.length === 1 && + finalAssistant?.agentId === mainAgentId && + runtimeState.lastResponse === runtimeResponsePreview && + latest.terminalDetail === runtimeResponsePreview, + 'response-stream-canonical-completion-invalid', + ); + const finalChars = [...finalText].length; + assert( + finalChars >= 80 && finalChars <= 32_000, + 'response-stream-final-body-size-invalid', + ); + state.responseStream.finalText = finalText; + + const streamFiles = ( + await listFiles( + path.join(state.projectRoot, '.agent/runtime/response-streams'), + ) + ).filter((file) => file.endsWith('.json')); + assert( + streamFiles.length === 1 && + path.resolve(streamFiles[0]) === + path.resolve(responseStreamSidecarPath()) && + stream.schemaVersion === 'game-creator-runtime-response-stream.v1' && + stream.agentId === mainAgentId && + stream.taskId === runtimeState.taskId && + stream.sessionId === state.initialSessionId && + stream.runId === state.initialRunId && + stream.requestKind === 'final-reply' && + stream.requestSlot === state.responseStream.finalRequestSlot && + stream.responseRevision === state.responseStream.finalResponseRevision && + stream.status === 'committed' && + stream.finishReason !== 'fallback' && + stream.accumulatedText === finalText, + 'response-stream-final-sidecar-invalid', + ); + + const nonEmptyStreaming = state.responseStream.observedSnapshots.filter( + (snapshot) => + snapshot.status === 'streaming' && + snapshot.chars > 0 && + snapshot.beforeTerminal, + ); + const distinctStreaming = [ + ...new Map( + nonEmptyStreaming.map((snapshot) => [snapshot.fingerprint, snapshot]), + ).values(), + ]; + assert( + distinctStreaming.length >= 2 && + state.responseStream.firstTerminalPoll != null && + distinctStreaming.every( + (snapshot, index) => + snapshot.pollOrdinal < state.responseStream.firstTerminalPoll && + snapshot.providerStartedCount === 1 && + snapshot.providerTerminalCount === 0 && + snapshot.assistantCount === 0 && + (index === 0 || + snapshot.sequence > distinctStreaming[index - 1].sequence), + ) && + stream.sequence > distinctStreaming.at(-1).sequence, + 'response-stream-preterminal-snapshot-evidence-invalid', + ); + + const providerLifecycle = validateResponseStreamProviderLifecycle( + agentDb, + stream, + ); + const finalization = validateResponseStreamFinalization( + agentDb, + runtimeState, + finalAssistant, + ); + const assistantAudits = agentDb.filter( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant' && + record.agentId === mainAgentId && + record.sessionId === state.initialSessionId && + record.messageId === finalMessage, + ); + const responseEvents = events.filter( + (event) => + event.agentId === mainAgentId && + event.runId === state.initialRunId && + event.eventType === 'response' && + event.phase === 'completed', + ); + const completedEvents = events.filter( + (event) => + event.agentId === mainAgentId && + event.runId === state.initialRunId && + event.eventType === 'turn.completed' && + event.phase === 'completed', + ); + assert( + assistantAudits.length === 1 && + responseEvents.length === 1 && + completedEvents.length === 1, + 'response-stream-canonical-audit-invalid', + ); + + const receipts = agentDb.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const publicSurfaces = { + event: events, + agentDb, + receipt: receipts, + activity, + output, + }; + const finalBodyPublicCounts = countSensitiveValuesBySurface( + publicSurfaces, + [finalText], + 'response-stream-final-body-public', + ); + const apiKeyPublicCounts = countSensitiveValuesBySurface( + publicSurfaces, + state.secrets, + 'response-stream-api-key-public', + ); + const thinkingPublicCounts = countSensitiveValuesBySurface( + publicSurfaces, + responseStreamThinkingMarkers, + 'response-stream-thinking-public', + ); + const lurePublicCounts = countSensitiveValuesBySurface( + publicSurfaces, + state.lures, + 'response-stream-lure-public', + ); + const projectPathPublicCounts = validateProjectRootPublicLeakBoundary( + publicSurfaces, + 'response-stream-public', + ); + + const privateVisibleSurfaces = Buffer.from( + JSON.stringify({ stream, conversations }), + ); + const privateSensitiveLeakCount = countExactSecrets(privateVisibleSurfaces, [ + ...state.secrets, + ...state.lures, + ...responseStreamThinkingMarkers, + ...disposableProjectPathVariants(), + ]); + assert( + privateSensitiveLeakCount === 0, + 'response-stream-private-visible-sensitive-leak', + ); + + const cliStatus = await runCli( + ['--agent-runtime-status', state.projectRoot, mainAgentId], + { timeoutMs: 60_000 }, + ); + const cliStatusBytes = Buffer.from( + `${cliStatus.stdout}\n${cliStatus.stderr}`, + ); + const cliStatusFinalBodyLeakCount = countExactSecrets(cliStatusBytes, [ + finalText, + ]); + const cliStatusSensitiveLeakCount = countExactSecrets(cliStatusBytes, [ + ...state.secrets, + ...state.lures, + ...responseStreamThinkingMarkers, + ...disposableProjectPathVariants(), + ]); + assert( + cliStatusFinalBodyLeakCount === 0 && cliStatusSensitiveLeakCount === 0, + 'response-stream-cli-status-private-body-leak', + ); + + const finalizationFiles = ( + await listFiles( + path.join(state.projectRoot, '.agent/runtime/finalizations'), + ) + ).filter((file) => file.endsWith('.json')); + assert( + finalizationFiles.length === 0, + 'response-stream-finalization-journal-present', + ); + const duplicateMessageCount = duplicateCount( + conversations.map((message) => message.messageId).filter(Boolean), + ); + const duplicateReceiptCount = duplicateCount( + receipts.map(receiptAuditIdentity), + ); + const responseIdentity = hashValue( + JSON.stringify([ + stream.requestSlot, + stream.responseRevision, + finalization.finalizationId, + finalization.messageId, + finalization.responseFingerprint, + ]), + ); + assert( + duplicateMessageCount === 0 && duplicateReceiptCount === 0, + 'response-stream-duplicate-persistence-identity', + ); + + state.lureLeakCount = await countLureLeaks(); + assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected'); + const projectSecretLeakCount = await countSecretsInProject( + state.projectRoot, + state.secrets, + ); + const secretLeakCount = + (state.transcriptScanner?.count ?? 0) + projectSecretLeakCount; + assert(secretLeakCount === 0, 'loaded-key-leak-detected'); + const protocolCount = validateMainRunToolPlanProtocols(agentDb); + + return { + scenario: 'single-background-final-reply-stream', + targetAgentId: mainAgentId, + targetAgentSelectionReason: + 'existing-harness-main-agent-initialization-boundary', + effectiveStreamEnabled: true, + streamOnlyConfigOverrideCreated: state.isolatedRunner.streamOverrideCreated, + isolatedAppDataUsed: true, + formalConfigCliCallCount: state.isolatedRunner.sourceConfigCliCallCount, + sourceRunnerEndpointUnchanged: false, + sourceConfigHardlinkCount: state.isolatedRunner.configLinks.length, + sourceConfigLinksVerified: false, + taskCount: taskSnapshot.all.length, + backgroundTaskEnqueueCount: 1, + targetRunCount: targetRunIds.length, + eventCount: events.length, + agentDbRecordCount: agentDb.length, + conversationMessageCount: conversations.length, + successfulToolExecutionCount: receipts.filter( + (record) => record.status === 'ok', + ).length, + toolPlanProtocolCount: protocolCount, + responseStreamFileCount: streamFiles.length, + responseStreamObservedSnapshotCount: + state.responseStream.observedSnapshots.length, + responseStreamPollCount: state.responseStream.pollCount, + responseStreamProjectVerifyConfirmationCount: + state.responseStream.confirmedProjectVerifyCount, + responseStreamCorrelatedSurfaceCount: 4, + responseStreamNonEmptyStreamingSnapshotCount: nonEmptyStreaming.length, + responseStreamDistinctStreamingSnapshotCount: distinctStreaming.length, + responseStreamFirstStreamingSequence: distinctStreaming[0].sequence, + responseStreamLastStreamingSequence: distinctStreaming.at(-1).sequence, + responseStreamCommittedSequence: stream.sequence, + responseStreamSnapshotsBeforeTerminal: true, + responseStreamFinalChars: finalization.responseChars, + responseStreamFinalFingerprint: finalization.responseFingerprint, + responseStreamRequestSlotHash: hashValue(providerLifecycle.requestSlot), + responseStreamRequestIdHash: hashValue(providerLifecycle.requestId), + providerLifecycleStartedCount: providerLifecycle.startedCount, + providerLifecycleTerminalCount: providerLifecycle.terminalCount, + providerPhysicalRequestCount: null, + providerPhysicalRequestCountDirectlyObserved: false, + providerPhysicalRequestProofMode: + 'lifecycle-slot-and-canonical-response-identity', + providerFallbackReplayCount: 0, + responseIdentityCount: 1, + duplicateResponseIdentityCount: 0, + responseIdentityHash: responseIdentity, + finalizationStageCount: finalization.stageCount, + finalizationJournalCount: finalizationFiles.length, + finalAssistantCount: assistantMessages.length, + finalAssistantAuditCount: assistantAudits.length, + duplicateMessageCount, + duplicateReceiptCount, + finalBodyPublicLeakCount: sumObjectValues(finalBodyPublicCounts), + apiKeyPublicLeakCount: sumObjectValues(apiKeyPublicCounts), + thinkingPublicLeakCount: sumObjectValues(thinkingPublicCounts), + lurePublicLeakCount: sumObjectValues(lurePublicCounts), + privateVisibleSensitiveLeakCount: privateSensitiveLeakCount, + cliStatusFinalBodyLeakCount, + cliStatusSensitiveLeakCount, + responseStreamPublicSurfaceCount: Object.keys(publicSurfaces).length, + responseStreamReportLeakCount: state.responseStream.reportLeakCount, + responseStreamRunnerKillMethod: null, + responseStreamRunnerPidfdClaimCount: state.isolatedRunner.pidfdClaimCount, + responseStreamRunnerPidfdSignalCount: state.isolatedRunner.pidfdSignalCount, + responseStreamRunnerStopped: false, + responseStreamAppDataCleanupPerformed: false, + projectPathPublicLeakCount: sumObjectValues(projectPathPublicCounts), + projectPathPublicSurfaceCount: Object.keys(projectPathPublicCounts).length, + secretLeakCount, + lureLeakCount: state.lureLeakCount, + paths: [ + '.agent/runtime/response-streams', + '.agent/runtime/tasks', + '.agent/runtime/events', + '.agent/agent.db', + '.agent/conversations', + ], + }; +} + +async function readGoalRuntimePersistence() { + const taskSnapshot = await readTaskSnapshot(); + const events = await readAllRuntimeEvents(); const [ agentDb, conversations, @@ -7909,6 +9114,87 @@ function emptyEvidence() { }; } +function emptyResponseStreamEvidence() { + return { + scenario: 'single-background-final-reply-stream', + targetAgentId: mainAgentId, + targetAgentSelectionReason: + 'existing-harness-main-agent-initialization-boundary', + effectiveStreamEnabled: false, + streamOnlyConfigOverrideCreated: false, + isolatedAppDataUsed: false, + formalConfigCliCallCount: 0, + sourceRunnerEndpointUnchanged: false, + sourceConfigHardlinkCount: 0, + sourceConfigLinksVerified: false, + taskCount: 0, + backgroundTaskEnqueueCount: 0, + targetRunCount: 0, + eventCount: 0, + agentDbRecordCount: 0, + conversationMessageCount: 0, + successfulToolExecutionCount: 0, + toolPlanProtocolCount: 0, + responseStreamFileCount: 0, + responseStreamObservedSnapshotCount: 0, + responseStreamPollCount: 0, + responseStreamCorrelatedSurfaceCount: 4, + responseStreamNonEmptyStreamingSnapshotCount: 0, + responseStreamDistinctStreamingSnapshotCount: 0, + responseStreamFirstStreamingSequence: null, + responseStreamLastStreamingSequence: null, + responseStreamCommittedSequence: null, + responseStreamSnapshotsBeforeTerminal: false, + responseStreamFinalChars: 0, + responseStreamFinalFingerprint: null, + responseStreamRequestSlotHash: null, + responseStreamRequestIdHash: null, + providerLifecycleStartedCount: 0, + providerLifecycleTerminalCount: 0, + providerPhysicalRequestCount: null, + providerPhysicalRequestCountDirectlyObserved: false, + providerPhysicalRequestProofMode: + 'lifecycle-slot-and-canonical-response-identity', + providerFallbackReplayCount: 0, + responseIdentityCount: 0, + duplicateResponseIdentityCount: 0, + responseIdentityHash: null, + finalizationStageCount: 0, + finalizationJournalCount: 0, + finalAssistantCount: 0, + finalAssistantAuditCount: 0, + duplicateMessageCount: 0, + duplicateReceiptCount: 0, + finalBodyPublicLeakCount: 0, + apiKeyPublicLeakCount: 0, + thinkingPublicLeakCount: 0, + lurePublicLeakCount: 0, + privateVisibleSensitiveLeakCount: 0, + cliStatusFinalBodyLeakCount: 0, + cliStatusSensitiveLeakCount: 0, + responseStreamPublicSurfaceCount: 0, + responseStreamReportLeakCount: 0, + responseStreamRunnerKillMethod: null, + responseStreamRunnerPidfdClaimCount: 0, + responseStreamRunnerPidfdSignalCount: 0, + responseStreamRunnerStopped: false, + responseStreamAppDataCleanupPerformed: false, + projectPathPublicLeakCount: 0, + projectPathPublicSurfaceCount: 0, + projectPathTranscriptLeakCount: 0, + projectPathReportLeakCount: 0, + secretLeakCount: 0, + lureLeakCount: 0, + failureEvidenceErrors: { + task: [], + event: [], + agentDb: [], + conversation: [], + }, + paths: [], + }; +} + function emptyGoalEvidence() { return { scenario: 'goal-edit-pause-runner-restart-resume', @@ -8007,23 +9293,160 @@ function emptyGoalEvidence() { }; } -async function collectPartialGoalEvidence() { +async function collectPartialResponseStreamEvidence() { const [taskSurface, eventSurface, agentDbSurface, conversationSurface] = await Promise.all([ - collectPartialGoalJsonlSurface('task', async () => + collectPartialRuntimeJsonlSurface('response-stream', 'task', async () => ( await listFiles(path.join(state.projectRoot, '.agent/runtime/tasks')) ).filter((file) => file.endsWith('.jsonl')), ), - collectPartialGoalJsonlSurface('event', async () => + collectPartialRuntimeJsonlSurface('response-stream', 'event', async () => ( await listFiles(path.join(state.projectRoot, '.agent/runtime/events')) ).filter((file) => file.endsWith('.jsonl')), ), - collectPartialGoalJsonlSurface('agent-db', async () => [ + collectPartialRuntimeJsonlSurface( + 'response-stream', + 'agent-db', + async () => [path.join(state.projectRoot, '.agent/agent.db')], + ), + collectPartialRuntimeJsonlSurface( + 'response-stream', + 'conversation', + async () => + ( + await listFiles( + path.join(state.projectRoot, '.agent/conversations'), + ) + ).filter((file) => file.endsWith('.jsonl')), + ), + ]); + const taskSnapshot = buildTaskSnapshot(taskSurface.records); + const agentDb = agentDbSurface.records; + const conversations = conversationSurface.records; + const stream = isNonEmptyString(state.initialRunId) + ? await readJson(responseStreamSidecarPath()).catch(() => null) + : null; + const providerLifecycle = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === mainAgentId && + (!state.initialRunId || record.runId === state.initialRunId) && + record.requestKind === 'final-reply', + ); + const finalizationLifecycle = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.finalization.lifecycle' && + record.agentId === mainAgentId && + (!state.initialRunId || record.runId === state.initialRunId), + ); + const nonEmptyStreaming = state.responseStream.observedSnapshots.filter( + (snapshot) => + snapshot.status === 'streaming' && + snapshot.chars > 0 && + snapshot.beforeTerminal, + ); + const distinctStreaming = new Set( + nonEmptyStreaming.map((snapshot) => snapshot.fingerprint), + ); + return { + effectiveStreamEnabled: state.responseStream.effectiveStreamEnabled, + streamOnlyConfigOverrideCreated: state.isolatedRunner.streamOverrideCreated, + isolatedAppDataUsed: Boolean(state.isolatedRunner.appDataDir), + formalConfigCliCallCount: state.isolatedRunner.sourceConfigCliCallCount, + sourceRunnerEndpointUnchanged: + state.isolatedRunner.sourceRunnerEndpointUnchanged, + sourceConfigHardlinkCount: state.isolatedRunner.configLinks.length, + sourceConfigLinksVerified: state.isolatedRunner.sourceConfigLinksVerified, + taskCount: taskSnapshot.all.length, + backgroundTaskEnqueueCount: isNonEmptyString(state.initialRunId) ? 1 : 0, + targetRunCount: new Set( + taskSnapshot.all + .filter((task) => task.agentId === mainAgentId) + .map((task) => task.runId), + ).size, + eventCount: eventSurface.records.length, + agentDbRecordCount: agentDb.length, + conversationMessageCount: conversations.length, + responseStreamFileCount: stream ? 1 : 0, + responseStreamObservedSnapshotCount: + state.responseStream.observedSnapshots.length, + responseStreamPollCount: state.responseStream.pollCount, + responseStreamNonEmptyStreamingSnapshotCount: nonEmptyStreaming.length, + responseStreamDistinctStreamingSnapshotCount: distinctStreaming.size, + responseStreamFirstStreamingSequence: + nonEmptyStreaming[0]?.sequence ?? null, + responseStreamLastStreamingSequence: + nonEmptyStreaming.at(-1)?.sequence ?? null, + responseStreamCommittedSequence: + stream?.status === 'committed' ? stream.sequence : null, + responseStreamSnapshotsBeforeTerminal: + distinctStreaming.size >= 2 && + state.responseStream.firstTerminalPoll != null, + responseStreamFinalChars: + typeof stream?.accumulatedText === 'string' + ? [...stream.accumulatedText].length + : 0, + responseStreamFinalFingerprint: + typeof stream?.accumulatedText === 'string' && + stream.accumulatedText.length > 0 + ? hashValue(stream.accumulatedText) + : null, + responseStreamRequestSlotHash: isNonEmptyString(stream?.requestSlot) + ? hashValue(stream.requestSlot) + : null, + providerLifecycleStartedCount: providerLifecycle.filter( + (record) => record.status === 'started', + ).length, + providerLifecycleTerminalCount: providerLifecycle.filter((record) => + ['completed', 'failed', 'interrupted'].includes(record.status), + ).length, + finalizationStageCount: finalizationLifecycle.length, + finalAssistantCount: conversations.filter( + (message) => message.role === 'assistant', + ).length, + finalAssistantAuditCount: agentDb.filter( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant', + ).length, + duplicateMessageCount: duplicateCount( + conversations.map((message) => message.messageId).filter(Boolean), + ), + failureEvidenceErrors: { + task: taskSurface.errors, + event: eventSurface.errors, + agentDb: agentDbSurface.errors, + conversation: conversationSurface.errors, + }, + paths: [ + '.agent/runtime/response-streams', + '.agent/runtime/tasks', + '.agent/runtime/events', + '.agent/agent.db', + '.agent/conversations', + ], + }; +} + +async function collectPartialGoalEvidence() { + const [taskSurface, eventSurface, agentDbSurface, conversationSurface] = + await Promise.all([ + collectPartialRuntimeJsonlSurface('goal', 'task', async () => + ( + await listFiles(path.join(state.projectRoot, '.agent/runtime/tasks')) + ).filter((file) => file.endsWith('.jsonl')), + ), + collectPartialRuntimeJsonlSurface('goal', 'event', async () => + ( + await listFiles(path.join(state.projectRoot, '.agent/runtime/events')) + ).filter((file) => file.endsWith('.jsonl')), + ), + collectPartialRuntimeJsonlSurface('goal', 'agent-db', async () => [ path.join(state.projectRoot, '.agent/agent.db'), ]), - collectPartialGoalJsonlSurface('conversation', async () => + collectPartialRuntimeJsonlSurface('goal', 'conversation', async () => ( await listFiles(path.join(state.projectRoot, '.agent/conversations')) ).filter((file) => file.endsWith('.jsonl')), @@ -8166,19 +9589,26 @@ async function collectPartialGoalEvidence() { }; } -async function collectPartialGoalJsonlSurface(surface, resolveFiles) { +async function collectPartialRuntimeJsonlSurface( + suitePrefix, + surface, + resolveFiles, +) { let files; try { files = await resolveFiles(); } catch { const errors = ['surface-list-failed']; - recordError(`goal-partial-${surface}-read-failed`, codedError(errors[0])); + recordError( + `${suitePrefix}-partial-${surface}-read-failed`, + codedError(errors[0]), + ); return { records: [], errors }; } const result = await readJsonlFilesPreservingValidRecords(files); if (result.errors.length > 0) { recordError( - `goal-partial-${surface}-read-failed`, + `${suitePrefix}-partial-${surface}-read-failed`, codedError([...new Set(result.errors)].join(',')), ); } @@ -8332,6 +9762,14 @@ function isGoalRuntimeSuite() { return state.suite === goalRuntimeSuite; } +function isResponseStreamSuite() { + return state.suite === responseStreamSuite; +} + +function isIsolatedRunnerSuite() { + return isGoalRuntimeSuite() || isResponseStreamSuite(); +} + function collectApiKeys(value, keys = []) { if (!value || typeof value !== 'object') return keys; if (Array.isArray(value)) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index dc6eb21d1..b79dd9d2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -38,6 +38,17 @@ const AGENT_RUNTIME_PROJECT_REVISION_RELATIVE_PATH: &str = ".agent/runtime/proje const AGENT_RUNTIME_SIDECAR_MAX_BYTES: usize = 16 * 1024; const AGENT_RUNTIME_PENDING_ACTION_SIDECAR_MAX_BYTES: usize = 512 * 1024; const AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES: usize = 512 * 1024; +const AGENT_RUNTIME_RESPONSE_STREAM_SCHEMA_VERSION: &str = + "game-creator-runtime-response-stream.v1"; +const AGENT_RUNTIME_RESPONSE_STREAM_SIDECAR_MAX_BYTES: usize = 256 * 1024; +const AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS: usize = 32_000; +const AGENT_RUNTIME_RESPONSE_STREAM_PERSIST_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(125); +pub(crate) const AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING: &str = "streaming"; +pub(crate) const AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY: &str = "ready"; +pub(crate) const AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED: &str = "committed"; +pub(crate) const AGENT_RUNTIME_RESPONSE_STREAM_STATUS_DISCARDED: &str = "discarded"; +pub(crate) const AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED: &str = "failed"; const AGENT_RUNTIME_STEER_SCHEMA_VERSION: &str = "game-creator-runtime-steer.v1"; pub(crate) const AGENT_RUNTIME_STEER_MAX_INSTRUCTION_BYTES: usize = 4 * 1024; const AGENT_RUNTIME_STEER_MAX_RUN_BYTES: usize = 16 * 1024; @@ -520,6 +531,8 @@ fn read_game_creator_agent_runtime_with_session_filter_at( let task_snapshot = read_game_creator_agent_runtime_task_snapshot_for_session(&task_path, session_id)?; state.task_queue = task_snapshot.task_queue.clone(); + let response_stream = + visible_game_creator_agent_runtime_response_stream_at(root, &state).unwrap_or(None); Ok(AgentRuntimeResult { state, session_path: session_path.to_string_lossy().into_owned(), @@ -528,6 +541,7 @@ fn read_game_creator_agent_runtime_with_session_filter_at( task_queue: task_snapshot.task_queue, recent_events, recent_tasks: task_snapshot.recent_tasks, + response_stream, }) } @@ -5308,14 +5322,44 @@ async fn run_game_creator_agent_background_task_pass_with_context( } converged = true; if !plan.response.trim().is_empty() { + let streamed_final_reply = + match game_creator_agent_runtime_streamed_final_reply_enabled(&root, &agent_id) + { + Ok(streamed) => streamed, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取最终回复流配置失败:{error}"), + ); + } + }; activate_agent_runtime_response_plan_step( &mut runtime, - "Agent 已直接给出最终回复。", + if streamed_final_reply { + "Agent 已收束工具计划,正在生成流式最终回复。" + } else { + "Agent 已直接给出最终回复。" + }, ); runtime.updated_at = unix_timestamp(); let _ = write_game_creator_agent_runtime_state(&root, &runtime); - final_reply = Some(plan.response.clone()); - final_reply_revision = Some(planning_request_revision.revision); + if !streamed_final_reply { + let response = strip_llm_thinking_blocks(&plan.response); + if response.trim().is_empty() { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "Agent 工具计划最终回复去除 thinking 后为空", + ); + } + final_reply = Some(response); + final_reply_revision = Some(planning_request_revision.revision); + } } observations = compact_agent_runtime_context_observations(&root, &observations); let _ = context_tracker.complete_loop(loop_index + 1); @@ -6172,6 +6216,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( &observations, runtime.applied_steer_cursor, &final_reply_request_slot, + response_revision, ) .await; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { @@ -6212,6 +6257,10 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); } } + let plan_fallback_response = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(&plan.response), + &observations, + ); let reply = match final_reply_result { Ok(Some(reply)) => reply, Ok(None) => { @@ -6232,7 +6281,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( { return AgentBackgroundTaskOutcome::NeedsReconciliation; } - Err(_) if !plan.response.trim().is_empty() => plan.response.clone(), + Err(_) if !plan_fallback_response.trim().is_empty() => plan_fallback_response, Err(error) => { let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = @@ -6281,6 +6330,11 @@ async fn run_game_creator_agent_background_task_pass_with_context( Ok(AgentBackgroundFinalizationOutcome::Completed(completed)) => { debug_assert_eq!(completed.run_id, runtime.run_id); debug_assert_eq!(completed.session_id, runtime.session_id); + let _ = mark_game_creator_agent_runtime_response_stream_committed_at( + &root, + &completed, + &final_reply, + ); AgentBackgroundTaskOutcome::Finished } Ok(AgentBackgroundFinalizationOutcome::Cancelled(cancelled)) => { @@ -8301,15 +8355,16 @@ where ) .is_err() { - let _control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + if let Ok(_control_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "runtime.provider_request.terminal_reconciliation", - )?; - mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( - root, - &snapshot, - &request_id, - )?; + ) { + let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + } return Err(format!( "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" )); @@ -9182,6 +9237,218 @@ where ) } +fn agent_runtime_response_stream_path_key(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) + .chars() + .take(32) + .collect() +} + +fn game_creator_agent_runtime_response_stream_relative_path( + agent_id: &str, + run_id: &str, +) -> String { + format!( + ".agent/runtime/response-streams/{}/{}.json", + agent_runtime_response_stream_path_key(agent_id), + agent_runtime_response_stream_path_key(run_id) + ) +} + +fn game_creator_agent_runtime_response_stream_request_slot( + state: &AgentRuntimeState, + response_revision: u64, +) -> String { + format!( + "final-reply-loop-{}-revision-{response_revision}", + state.loop_iteration + ) +} + +pub(crate) fn game_creator_agent_runtime_response_stream_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> PathBuf { + root.join(game_creator_agent_runtime_response_stream_relative_path( + agent_id, run_id, + )) +} + +fn agent_runtime_response_stream_status_is_valid(status: &str) -> bool { + matches!( + status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_DISCARDED + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED + ) +} + +fn game_creator_agent_runtime_streamed_final_reply_enabled( + root: &Path, + agent_id: &str, +) -> Result { + let template_agent_id = game_creator_runtime_template_agent_id_at(root, agent_id)?; + let app_config = load_game_creator_app_config()?; + Ok(resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id).stream) +} + +fn validate_game_creator_agent_runtime_response_stream( + stream: &AgentRuntimeResponseStream, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + if stream.schema_version != AGENT_RUNTIME_RESPONSE_STREAM_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent Runtime 回复流版本:{}", + stream.schema_version + )); + } + let normalized_agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + if stream.agent_id != normalized_agent_id + || stream.agent_id != normalize_game_creator_runtime_agent_id(&stream.agent_id)? + || stream.run_id != run_id + || stream.task_id.trim().is_empty() + || stream.session_id.trim().is_empty() + || stream.run_id.trim().is_empty() + || stream.request_kind != "final-reply" + || stream.request_slot.trim().is_empty() + { + return Err("Agent Runtime 回复流身份无效".to_string()); + } + if !agent_runtime_response_stream_status_is_valid(&stream.status) { + return Err(format!("Agent Runtime 回复流状态无效:{}", stream.status)); + } + if stream.accumulated_text.chars().count() > AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS { + return Err(format!( + "Agent Runtime 回复流正文超过 {} 字符上限", + AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS + )); + } + if matches!( + stream.status.as_str(), + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ) && stream.accumulated_text.trim().is_empty() + { + return Err("Agent Runtime 回复流终态正文不能为空".to_string()); + } + if stream + .finish_reason + .as_deref() + .is_some_and(|reason| reason.chars().count() > 80 || reason.chars().any(char::is_control)) + { + return Err("Agent Runtime 回复流 finishReason 无效".to_string()); + } + if stream.started_at == 0 || stream.updated_at < stream.started_at { + return Err("Agent Runtime 回复流时间顺序无效".to_string()); + } + Ok(()) +} + +pub(crate) fn read_game_creator_agent_runtime_response_stream_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let relative_path = game_creator_agent_runtime_response_stream_relative_path(&agent_id, run_id); + let stream = read_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Agent Runtime 回复流", + AGENT_RUNTIME_RESPONSE_STREAM_SIDECAR_MAX_BYTES, + )?; + if let Some(stream) = stream.as_ref() { + validate_game_creator_agent_runtime_response_stream(stream, &agent_id, run_id)?; + } + Ok(stream) +} + +fn write_game_creator_agent_runtime_response_stream_at( + root: &Path, + stream: &AgentRuntimeResponseStream, +) -> Result<(), String> { + validate_game_creator_agent_runtime_response_stream(stream, &stream.agent_id, &stream.run_id)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &game_creator_agent_runtime_response_stream_relative_path(&stream.agent_id, &stream.run_id), + "Agent Runtime 回复流", + stream, + AGENT_RUNTIME_RESPONSE_STREAM_SIDECAR_MAX_BYTES, + ) +} + +fn visible_game_creator_agent_runtime_response_stream_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + if state.run_id.trim().is_empty() || state.queued_steer_count > 0 { + return Ok(None); + } + let Some(stream) = + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id)? + else { + return Ok(None); + }; + if stream.task_id != state.task_id + || stream.session_id != state.session_id + || stream.applied_steer_cursor != state.applied_steer_cursor + || stream.response_revision + != read_game_creator_agent_runtime_project_revision(root)?.revision + || stream.request_slot + != game_creator_agent_runtime_response_stream_request_slot( + state, + stream.response_revision, + ) + { + return Ok(None); + } + let visible = match stream.status.as_str() { + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY => { + state.status == "running" && matches!(state.phase.as_str(), "response" | "finalizing") + } + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED => { + matches!(state.status.as_str(), "idle" | "completed") && state.phase == "completed" + } + _ => false, + }; + Ok(visible.then_some(stream)) +} + +fn mark_game_creator_agent_runtime_response_stream_committed_at( + root: &Path, + state: &AgentRuntimeState, + response: &str, +) -> Result<(), String> { + let Some(mut stream) = + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id)? + else { + return Ok(()); + }; + if stream.task_id != state.task_id + || stream.session_id != state.session_id + || stream.applied_steer_cursor != state.applied_steer_cursor + || stream.response_revision + != read_game_creator_agent_runtime_project_revision(root)?.revision + || stream.request_slot + != game_creator_agent_runtime_response_stream_request_slot( + state, + stream.response_revision, + ) + || stream.status != AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY + || stream.accumulated_text != response + { + return Ok(()); + } + stream.status = AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED.to_string(); + stream.sequence = stream.sequence.saturating_add(1); + stream.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_response_stream_at(root, &stream) +} + pub(crate) fn game_creator_agent_runtime_project_revision_path(root: &Path) -> PathBuf { root.join(AGENT_RUNTIME_PROJECT_REVISION_RELATIVE_PATH) } @@ -13160,7 +13427,7 @@ async fn request_game_creator_agent_background_tool_plan_at( )?; (snapshot, request) }; - let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; for repair_attempt in 0..=AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { return Err("Agent 后台任务已收到取消请求".to_string()); @@ -13277,6 +13544,684 @@ async fn request_game_creator_agent_background_tool_plan_at( unreachable!("工具计划格式修复循环必须返回结果") } +#[derive(Default)] +struct AgentRuntimeThinkingStreamFilter { + pending: String, + inside_thinking: bool, +} + +fn ascii_marker_suffix_len(value: &str, marker: &str) -> usize { + let max_len = value.len().min(marker.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|length| { + let start = value.len() - *length; + value.is_char_boundary(start) && value[start..].eq_ignore_ascii_case(&marker[..*length]) + }) + .unwrap_or(0) +} + +impl AgentRuntimeThinkingStreamFilter { + fn push(&mut self, delta: &str) -> String { + const THINK_START: &str = ""; + const THINK_END: &str = ""; + + self.pending.push_str(delta); + let mut visible = String::new(); + loop { + if self.inside_thinking { + if let Some(end) = self.pending.to_ascii_lowercase().find(THINK_END) { + self.pending.drain(..end + THINK_END.len()); + self.inside_thinking = false; + continue; + } + let keep = ascii_marker_suffix_len(&self.pending, THINK_END); + if keep == 0 { + self.pending.clear(); + } else { + let start = self.pending.len() - keep; + self.pending = self.pending[start..].to_string(); + } + break; + } + + if let Some(start) = self.pending.to_ascii_lowercase().find(THINK_START) { + visible.push_str(&self.pending[..start]); + self.pending.drain(..start + THINK_START.len()); + self.inside_thinking = true; + continue; + } + let keep = ascii_marker_suffix_len(&self.pending, THINK_START); + let split = self.pending.len() - keep; + visible.push_str(&self.pending[..split]); + self.pending = self.pending[split..].to_string(); + break; + } + visible + } +} + +fn normalize_agent_runtime_response_stream_finish_reason( + finish_reason: Option<&str>, +) -> Option { + finish_reason.and_then(|reason| { + let reason = reason.trim(); + (!reason.is_empty() && !reason.chars().any(char::is_control)) + .then(|| truncate_agent_runtime_text(reason, 80)) + }) +} + +fn build_game_creator_agent_runtime_response_stream( + snapshot: &AgentRuntimeProviderRequestSnapshot, + response_revision: u64, + status: &str, + sequence: u64, + accumulated_text: String, + finish_reason: Option<&str>, +) -> AgentRuntimeResponseStream { + let now = unix_timestamp(); + AgentRuntimeResponseStream { + schema_version: AGENT_RUNTIME_RESPONSE_STREAM_SCHEMA_VERSION.to_string(), + agent_id: snapshot.agent_id.clone(), + task_id: snapshot.task_id.clone(), + session_id: snapshot.session_id.clone(), + run_id: snapshot.run_id.clone(), + request_kind: snapshot.request_kind.clone(), + request_slot: snapshot.request_slot.clone(), + applied_steer_cursor: snapshot.applied_steer_cursor, + response_revision, + sequence, + status: status.to_string(), + accumulated_text, + finish_reason: normalize_agent_runtime_response_stream_finish_reason(finish_reason), + started_at: now, + updated_at: now, + } +} + +fn write_game_creator_agent_runtime_response_stream_ready_at( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + response_revision: u64, + response: &str, + finish_reason: Option<&str>, +) -> Result<(), String> { + if response.trim().is_empty() { + return Err("Agent Runtime 回复流 ready 正文不能为空".to_string()); + } + if response.chars().count() > AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS { + return Err(format!( + "Agent Runtime 回复流 ready 正文超过 {} 字符上限", + AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS + )); + } + let previous = read_game_creator_agent_runtime_response_stream_at( + root, + &snapshot.agent_id, + &snapshot.run_id, + )? + .filter(|stream| { + stream.task_id == snapshot.task_id + && stream.session_id == snapshot.session_id + && stream.request_slot == snapshot.request_slot + && stream.applied_steer_cursor == snapshot.applied_steer_cursor + && stream.response_revision == response_revision + }); + let mut stream = build_game_creator_agent_runtime_response_stream( + snapshot, + response_revision, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + previous + .as_ref() + .map(|stream| stream.sequence.saturating_add(1)) + .unwrap_or(1), + response.to_string(), + finish_reason, + ); + if let Some(previous) = previous { + stream.started_at = previous.started_at; + } + write_game_creator_agent_runtime_response_stream_at(root, &stream) +} + +struct AgentRuntimeResponseStreamPublisher { + root: PathBuf, + stream: AgentRuntimeResponseStream, + filter: AgentRuntimeThinkingStreamFilter, + last_persisted_at: std::time::Instant, + dirty: bool, + terminal: bool, +} + +impl AgentRuntimeResponseStreamPublisher { + fn start( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + response_revision: u64, + ) -> Self { + let stream = build_game_creator_agent_runtime_response_stream( + snapshot, + response_revision, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + 0, + String::new(), + None, + ); + let _ = write_game_creator_agent_runtime_response_stream_at(root, &stream); + let now = std::time::Instant::now(); + Self { + root: root.to_path_buf(), + stream, + filter: AgentRuntimeThinkingStreamFilter::default(), + last_persisted_at: now + .checked_sub(AGENT_RUNTIME_RESPONSE_STREAM_PERSIST_INTERVAL) + .unwrap_or(now), + dirty: false, + terminal: false, + } + } + + fn push(&mut self, delta: &platform_llm::LlmStreamDelta) { + let visible_delta = self.filter.push(&delta.delta_text); + let remaining = AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS + .saturating_sub(self.stream.accumulated_text.chars().count()); + let visible_delta = visible_delta.chars().take(remaining).collect::(); + let finish_reason = + normalize_agent_runtime_response_stream_finish_reason(delta.finish_reason.as_deref()); + if visible_delta.is_empty() && finish_reason == self.stream.finish_reason { + return; + } + self.stream.accumulated_text.push_str(&visible_delta); + self.stream.finish_reason = finish_reason; + self.stream.sequence = self.stream.sequence.saturating_add(1); + self.stream.updated_at = unix_timestamp(); + self.dirty = true; + if self.last_persisted_at.elapsed() >= AGENT_RUNTIME_RESPONSE_STREAM_PERSIST_INTERVAL + || self.stream.finish_reason.is_some() + { + self.persist(); + } + } + + fn ready(&mut self, response: &str, finish_reason: Option<&str>) { + if response.trim().is_empty() + || response.chars().count() > AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS + { + self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED); + return; + } + self.stream.accumulated_text = response.to_string(); + self.stream.finish_reason = + normalize_agent_runtime_response_stream_finish_reason(finish_reason); + self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY); + } + + fn failed(&mut self) { + self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED); + } + + fn finish_with_status(&mut self, status: &str) { + self.stream.status = status.to_string(); + self.stream.sequence = self.stream.sequence.saturating_add(1); + self.stream.updated_at = unix_timestamp(); + self.dirty = true; + self.persist(); + self.terminal = true; + } + + fn persist(&mut self) { + if !self.dirty { + return; + } + let _ = write_game_creator_agent_runtime_response_stream_at(&self.root, &self.stream); + self.last_persisted_at = std::time::Instant::now(); + self.dirty = false; + } +} + +impl Drop for AgentRuntimeResponseStreamPublisher { + fn drop(&mut self) { + if self.terminal { + return; + } + self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_DISCARDED); + } +} + +#[cfg(test)] +pub(crate) fn filter_agent_runtime_response_stream_for_test(chunks: &[&str]) -> String { + let mut filter = AgentRuntimeThinkingStreamFilter::default(); + chunks + .iter() + .map(|chunk| filter.push(chunk)) + .collect::() +} + +#[cfg(test)] +mod response_stream_tests { + use super::*; + + fn response_stream_fixture( + run_id: &str, + ) -> ( + tempfile::TempDir, + AgentRuntimeState, + u64, + AgentRuntimeProviderRequestSnapshot, + ) { + let project = tempfile::tempdir().expect("create response stream project"); + let root = project.path(); + init_local_game_project_at(root, "response-stream-project", "回复流测试项目") + .expect("initialize response stream project"); + let mut state = start_game_creator_agent_runtime_task_at( + root, + "project-supervisor", + "验证后台最终回复真流式展示", + run_id, + "agent-background-task", + "准备最终回复", + vec!["生成唯一最终回复".to_string()], + ) + .expect("start response stream runtime"); + state.loop_iteration = 1; + state.status = "running".to_string(); + state.phase = "response".to_string(); + state.current_action = "根据观察生成最终回复".to_string(); + state.waiting_on = "LLM 最终回复".to_string(); + state.next_step = "完成最终化".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state) + .expect("append response stream task projection"); + write_game_creator_agent_runtime_state(root, &state) + .expect("write response stream runtime state"); + let response_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read response stream revision") + .revision; + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: game_creator_agent_runtime_context_project_id(root) + .expect("read response stream project id"), + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: state.applied_steer_cursor, + request_kind: "final-reply".to_string(), + request_slot: game_creator_agent_runtime_response_stream_request_slot( + &state, + response_revision, + ), + }; + (project, state, response_revision, snapshot) + } + + fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmStreamDelta { + platform_llm::LlmStreamDelta { + accumulated_text: accumulated_text.to_string(), + delta_text: delta_text.to_string(), + finish_reason: None, + } + } + + #[test] + fn response_stream_filter_hides_thinking_markers_split_across_deltas() { + let visible = filter_agent_runtime_response_stream_for_test(&[ + "给你不能泄露", + "最终", + "答复", + ]); + assert_eq!(visible, "给你最终答复"); + assert!(!visible.contains("不能泄露")); + assert!(!visible.to_ascii_lowercase().contains("think")); + } + + #[test] + fn response_stream_publisher_persists_monotonic_visible_deltas() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-multi-delta-run"); + let root = project.path(); + let mut publisher = + AgentRuntimeResponseStreamPublisher::start(root, &snapshot, response_revision); + let started = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read started response stream") + .expect("started response stream exists"); + assert_eq!( + started.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING + ); + assert_eq!(started.sequence, 0); + assert!(started.accumulated_text.is_empty()); + + publisher.push(&stream_delta("你好,", "你好,")); + publisher.persist(); + let first = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read first response stream delta") + .expect("first response stream delta exists"); + assert_eq!(first.sequence, 1); + assert_eq!(first.accumulated_text, "你好,"); + + publisher.push(&stream_delta("内部推理内部推理这是总控回复。", + "你好,内部推理这是总控回复。", + )); + publisher.persist(); + let second = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read second response stream delta") + .expect("second response stream delta exists"); + assert_eq!(second.sequence, 2); + assert_eq!(second.accumulated_text, "你好,这是总控回复。"); + assert!(!second.accumulated_text.contains("内部推理")); + + publisher.ready("你好,这是总控回复。", Some("stop")); + let ready = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read ready response stream") + .expect("ready response stream exists"); + assert_eq!(ready.status, AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY); + assert_eq!(ready.sequence, 3); + assert_eq!(ready.accumulated_text, "你好,这是总控回复。"); + assert_eq!(ready.finish_reason.as_deref(), Some("stop")); + } + + #[test] + fn response_stream_interrupted_publisher_is_discarded_and_hidden() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-interrupted-run"); + let root = project.path(); + { + let mut publisher = + AgentRuntimeResponseStreamPublisher::start(root, &snapshot, response_revision); + publisher.push(&stream_delta("未完成的半句", "未完成的半句")); + publisher.persist(); + } + let discarded = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read discarded response stream") + .expect("discarded response stream exists"); + assert_eq!( + discarded.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_DISCARDED + ); + assert_eq!(discarded.sequence, 2); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &state) + .expect("resolve discarded response stream visibility") + .is_none() + ); + assert!(read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("read runtime after interrupted stream") + .response_stream + .is_none()); + } + + #[test] + fn response_stream_visibility_rejects_stale_identity_revision_steer_phase_and_slot() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-identity-run"); + let root = project.path(); + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + "只属于当前请求槽的回复", + Some("stop"), + ) + .expect("write visible ready response stream"); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &state) + .expect("resolve valid response stream visibility") + .is_some() + ); + + let mut wrong_task = state.clone(); + wrong_task.task_id = "another-task".to_string(); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &wrong_task) + .expect("resolve wrong task visibility") + .is_none() + ); + let mut wrong_session = state.clone(); + wrong_session.session_id = "another-session".to_string(); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &wrong_session) + .expect("resolve wrong session visibility") + .is_none() + ); + let mut wrong_steer = state.clone(); + wrong_steer.applied_steer_cursor = 1; + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &wrong_steer) + .expect("resolve wrong steer visibility") + .is_none() + ); + let mut queued_steer = state.clone(); + queued_steer.queued_steer_count = 1; + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &queued_steer) + .expect("resolve queued steer visibility") + .is_none() + ); + let mut wrong_phase = state.clone(); + wrong_phase.phase = "planning".to_string(); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &wrong_phase) + .expect("resolve wrong phase visibility") + .is_none() + ); + + let mut wrong_slot = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read response stream before slot tamper") + .expect("response stream exists before slot tamper"); + wrong_slot.request_slot = "final-reply-loop-999-revision-0".to_string(); + write_game_creator_agent_runtime_response_stream_at(root, &wrong_slot) + .expect("write wrong request slot stream"); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &state) + .expect("resolve wrong slot visibility") + .is_none() + ); + + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + "只属于当前 revision 的回复", + Some("stop"), + ) + .expect("restore response stream before revision change"); + let mut revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read revision before drift"); + revision.revision = revision.revision.saturating_add(1); + revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(root, &revision) + .expect("write drifted project revision"); + assert!( + visible_game_creator_agent_runtime_response_stream_at(root, &state) + .expect("resolve stale revision visibility") + .is_none() + ); + } + + #[test] + fn response_stream_corrupt_or_oversized_sidecar_does_not_poison_runtime() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-corrupt-run"); + let root = project.path(); + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + "合法回复", + Some("stop"), + ) + .expect("write valid response stream before corruption"); + let path = + game_creator_agent_runtime_response_stream_path(root, &state.agent_id, &state.run_id); + let mut unknown_field = serde_json::to_value( + read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read valid response stream") + .expect("valid response stream exists"), + ) + .expect("serialize response stream for corruption"); + unknown_field["privateReasoning"] = serde_json::json!("不得进入回复流"); + std::fs::write( + &path, + serde_json::to_vec(&unknown_field).expect("serialize corrupted response stream"), + ) + .expect("write unknown response stream field"); + assert!(read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .is_err()); + let runtime = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("corrupt display cache must not poison runtime"); + assert_eq!(runtime.state.run_id, state.run_id); + assert!(runtime.response_stream.is_none()); + + std::fs::write( + &path, + vec![b'x'; AGENT_RUNTIME_RESPONSE_STREAM_SIDECAR_MAX_BYTES + 1], + ) + .expect("write oversized response stream sidecar"); + assert!(read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .is_err()); + let runtime = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("oversized display cache must not poison runtime"); + assert_eq!(runtime.state.run_id, state.run_id); + assert!(runtime.response_stream.is_none()); + } + + #[test] + fn response_stream_finalization_commits_exactly_one_canonical_assistant() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-finalization-run"); + let root = project.path(); + let response = "项目总监已经汇总完成。"; + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + response, + Some("stop"), + ) + .expect("write ready response stream before finalization"); + let ready = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read ready response stream before finalization") + .expect("ready response stream exists before finalization"); + assert_eq!(ready.sequence, 1); + + let completed = match finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + response, + response_revision, + &[], + ) + .expect("finalize response stream assistant") + { + AgentBackgroundFinalizationOutcome::Completed(completed) => completed, + AgentBackgroundFinalizationOutcome::Pending(error) => { + panic!("response stream finalization remained pending: {error}") + } + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + panic!( + "response stream finalization became stale: {}", + blocker.summary + ) + } + AgentBackgroundFinalizationOutcome::Cancelled(_) => { + panic!("response stream finalization was cancelled") + } + }; + mark_game_creator_agent_runtime_response_stream_committed_at(root, &completed, response) + .expect("mark finalized response stream committed"); + + let committed = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read committed response stream") + .expect("committed response stream exists"); + assert_eq!( + committed.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ); + assert_eq!(committed.sequence, 2); + assert_eq!(committed.accumulated_text, response); + let runtime = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("read completed runtime with committed response stream"); + assert_eq!(runtime.state.status, "idle"); + assert_eq!(runtime.state.phase, "completed"); + assert_eq!( + runtime + .response_stream + .as_ref() + .map(|stream| stream.status.as_str()), + Some(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED) + ); + let conversation = read_local_conversation_for_session_at( + root, + Some(&state.agent_id), + Some(&state.session_id), + ) + .expect("read finalized response stream conversation"); + let assistants = conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(assistants, vec![response]); + } +} + async fn request_game_creator_agent_background_final_reply_at( root: &Path, agent_id: &str, @@ -13287,6 +14232,7 @@ async fn request_game_creator_agent_background_final_reply_at( observations: &[AgentRuntimeToolObservation], applied_steer_cursor: u64, request_slot: &str, + response_revision: u64, ) -> Result, String> { let (provider_snapshot, (llm, config_path, request)) = { let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( @@ -13313,28 +14259,104 @@ async fn request_game_creator_agent_background_final_reply_at( )?; (snapshot, request) }; - let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; + let stream_snapshot = provider_snapshot.clone(); + let suppress_private_process_output = + agent_runtime_observations_contain_private_process_output(observations); + let fallback_response = (!plan.response.trim().is_empty()) + .then(|| strip_llm_thinking_blocks(&plan.response)) + .map(|response| { + redact_agent_runtime_private_process_output_from_response(&response, observations) + }) + .filter(|response| !response.trim().is_empty()); let provider_request = async { - request_game_creator_llm_text(&client, &llm, request) - .await - .map_err(|error| { - format!( - "{config_path} 后台 Agent 最终回复调用 LLM 失败:{}", - game_creator_agent_llm_error_public_summary(&error) - ) - }) + let response = if llm.stream { + let mut publisher = AgentRuntimeResponseStreamPublisher::start( + root, + &stream_snapshot, + response_revision, + ); + match client + .stream_run(request, |delta| { + if !suppress_private_process_output { + publisher.push(delta); + } + }) + .await + { + Ok(response) => { + let reply = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(response.text.as_str()), + observations, + ); + publisher.ready(&reply, response.finish_reason.as_deref()); + Ok(response) + } + Err(error) => { + publisher.failed(); + Err(error) + } + } + } else { + match client.run(request).await { + Ok(response) => { + let reply = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(response.text.as_str()), + observations, + ); + let _ = write_game_creator_agent_runtime_response_stream_ready_at( + root, + &stream_snapshot, + response_revision, + &reply, + response.finish_reason.as_deref(), + ); + Ok(response) + } + Err(error) => Err(error), + } + }; + response.map_err(|error| { + format!( + "{config_path} 后台 Agent 最终回复调用 LLM 失败:{}", + game_creator_agent_llm_error_public_summary(&error) + ) + }) }; - let Some(response) = await_game_creator_agent_runtime_provider_request_with_snapshot( + let response_result = await_game_creator_agent_runtime_provider_request_with_snapshot( root, provider_snapshot, provider_request, ) - .await? - else { + .await; + if response_result.is_err() { + if let Some(fallback_response) = fallback_response.as_deref() { + let _ = write_game_creator_agent_runtime_response_stream_ready_at( + root, + &stream_snapshot, + response_revision, + fallback_response, + Some("fallback"), + ); + } + } + let Some(response) = response_result? else { return Ok(None); }; - let reply = strip_llm_thinking_blocks(response.text.as_str()); + let reply = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(response.text.as_str()), + observations, + ); if reply.trim().is_empty() { + if let Some(fallback_response) = fallback_response.as_deref() { + let _ = write_game_creator_agent_runtime_response_stream_ready_at( + root, + &stream_snapshot, + response_revision, + fallback_response, + Some("fallback"), + ); + } return Err(format!("{config_path} 后台 Agent 最终回复为空")); } Ok(Some(reply)) @@ -18933,9 +19955,16 @@ pub(crate) async fn observe_agent_runtime_project_verify( ) .await .and_then(|verification| { - let audit_output = truncate_agent_runtime_text_preserving_tail( - sanitize_prompt_context(&verification.output).as_str(), - 4_000, + let audit_log_path = relative_project_path(root, Path::new(&verification.log_path))?; + if audit_log_path != ".agent/logs/command.log" { + return Err(format!("project.verify 命令日志路径无效:{audit_log_path}")); + } + let audit_output = + redact_agent_runtime_project_paths_preserving_tail(root, &verification.output, 4_000); + let audit_expected_command = redact_agent_runtime_project_paths( + root, + &sanitize_project_verification_output(&verification.expected_command), + 1_000, ); append_agent_db_record( root, @@ -18947,10 +19976,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( "actionFingerprint": action_fingerprint, "commandId": verification.command_id, "script": verification.script, - "expectedCommand": truncate_agent_runtime_text( - &sanitize_project_verification_output(&verification.expected_command), - 1_000, - ), + "expectedCommand": audit_expected_command, "packageManager": verification.package_manager, "status": verification.status, "exitCode": verification.exit_code, @@ -18963,7 +19989,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( "sandboxEstablishment": verification.sandbox_establishment, "targetExec": verification.target_exec, "launchFailureKind": verification.launch_failure_kind, - "logPath": verification.log_path, + "logPath": audit_log_path, "output": audit_output, }), ) @@ -22997,7 +24023,17 @@ pub(crate) fn redact_agent_runtime_private_process_output_from_response( response: &str, observations: &[AgentRuntimeToolObservation], ) -> String { - let has_private_process_output = observations.iter().any(|observation| { + if agent_runtime_observations_contain_private_process_output(observations) { + "持久进程交互已完成,私有进程输出已省略。".to_string() + } else { + response.trim().to_string() + } +} + +fn agent_runtime_observations_contain_private_process_output( + observations: &[AgentRuntimeToolObservation], +) -> bool { + observations.iter().any(|observation| { observation.tool == "command.poll" && observation.status == "ok" && observation @@ -23011,12 +24047,7 @@ pub(crate) fn redact_agent_runtime_private_process_output_from_response( .map(|output| !output.is_empty()) }) .unwrap_or(false) - }); - if has_private_process_output { - "持久进程交互已完成,私有进程输出已省略。".to_string() - } else { - response.trim().to_string() - } + }) } pub(crate) fn finish_game_creator_agent_runtime_turn_at( @@ -23196,6 +24227,11 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( ) -> Result { let mut completed = prepare_game_creator_agent_runtime_completed_state(root, state, response)?; let expected_terminal_detail = completed.last_response.clone().unwrap_or_default(); + let public_response_detail = format!( + "responseSha256={} responseChars={}", + journal.response_fingerprint, + journal.response.chars().count() + ); match read_latest_game_creator_agent_runtime_task_by_run_id( root, &completed.agent_id, @@ -23263,7 +24299,7 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( "idle", "completed", "Agent Runtime 完成本轮处理。", - completed.last_response.as_deref(), + Some(&public_response_detail), )?; } let completed_audit = serde_json::json!({ @@ -23305,7 +24341,7 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( completed.status.as_str(), completed.phase.as_str(), "Agent 已生成最终回复。", - completed.last_response.as_deref(), + Some(&public_response_detail), )?; } let background_completed_audit = serde_json::json!({ @@ -27235,7 +28271,7 @@ fn build_game_creator_agent_runtime_llm_client( ) -> Result { let mut single_attempt = llm.clone(); single_attempt.max_retries = 0; - build_game_creator_llm_client_from_llm_config(&single_attempt, config_path) + build_game_creator_llm_client_without_redirects_from_llm_config(&single_attempt, config_path) } pub(crate) fn game_creator_agent_llm_error_public_summary( diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 727f56bf6..e0254df85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4,6 +4,23 @@ pub(crate) fn build_game_creator_llm_client_from_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { + let config = build_game_creator_platform_llm_config(llm, config_path)?; + LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) +} + +pub(crate) fn build_game_creator_llm_client_without_redirects_from_llm_config( + llm: &GameCreatorLlmConfig, + config_path: &str, +) -> Result { + let config = build_game_creator_platform_llm_config(llm, config_path)?; + LlmClient::new_without_redirects(config) + .map_err(|error| format!("LLM client 初始化失败:{error}")) +} + +fn build_game_creator_platform_llm_config( + llm: &GameCreatorLlmConfig, + config_path: &str, +) -> Result { let api_key = trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?; let base_url = @@ -11,7 +28,7 @@ pub(crate) fn build_game_creator_llm_client_from_llm_config( let model = trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?; validate_game_creator_llm_timing_config(llm, config_path)?; - let config = LlmConfig::new( + LlmConfig::new( LlmProvider::OpenAiCompatible, base_url, api_key, @@ -20,9 +37,7 @@ pub(crate) fn build_game_creator_llm_client_from_llm_config( llm.max_retries, llm.retry_backoff_ms, ) - .map_err(|error| format!("LLM 配置无效:{error}"))?; - - LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) + .map_err(|error| format!("LLM 配置无效:{error}")) } pub(crate) fn build_game_creator_llm_client_from_config() -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 647ee21bb..790584a54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -421,6 +421,26 @@ struct AgentRuntimeEvent { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeResponseStream { + schema_version: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + request_kind: String, + request_slot: String, + applied_steer_cursor: u64, + response_revision: u64, + sequence: u64, + status: String, + accumulated_text: String, + finish_reason: Option, + started_at: u64, + updated_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeTaskRecord { @@ -514,6 +534,7 @@ struct AgentRuntimeResult { task_queue: AgentRuntimeTaskQueueSummary, recent_events: Vec, recent_tasks: Vec, + response_stream: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index be63a5d21..c97131a09 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -42,6 +42,44 @@ struct SwarmRuntimeObserver { state_signatures: BTreeMap, seen_events: BTreeSet, handled_confirmations: BTreeSet, + response_streams: BTreeMap, + open_response_line: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmResponseStreamIdentity { + task_id: String, + session_id: String, + run_id: String, + request_slot: String, + applied_steer_cursor: u64, + response_revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmResponseStreamCursor { + identity: SwarmResponseStreamIdentity, + session_id: String, + sequence: u64, + status: String, + accumulated_text: String, + printed_accumulated_text: Option, + connected: bool, + rejected_snapshot: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmResponseStreamLine { + agent_id: String, + identity: SwarmResponseStreamIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmRejectedResponseStreamSnapshot { + session_id: String, + sequence: u64, + status: String, + accumulated_text: String, } #[derive(Debug, Eq, PartialEq)] @@ -672,6 +710,7 @@ fn print_swarm_status(root: &Path, output: &mut W) -> Result<(), Strin || runtime.task_queue.waiting_for_confirmation > 0 }) { print_runtime_state(&runtime.state, &runtime.task_queue, output)?; + print_runtime_response_stream_status(runtime.response_stream.as_ref(), output)?; } if runtimes .iter() @@ -683,6 +722,23 @@ fn print_swarm_status(root: &Path, output: &mut W) -> Result<(), Strin Ok(()) } +fn print_runtime_response_stream_status( + stream: Option<&AgentRuntimeResponseStream>, + output: &mut W, +) -> Result<(), String> { + let Some(stream) = stream else { + return Ok(()); + }; + writeln!( + output, + "[回复流] status={} sequence={} chars={}", + stream.status, + stream.sequence, + stream.accumulated_text.chars().count() + ) + .map_err(|error| format!("写入终端失败:{error}")) +} + fn print_conversation_history( root: &Path, parent_agent_id: &str, @@ -736,6 +792,7 @@ fn wait_for_swarm_turn( } let mut reconciliation = swarm_reconciliation_agents(&runtimes); if !reconciliation.is_empty() { + observer.close_response_line(output)?; return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); } match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { @@ -752,6 +809,7 @@ fn wait_for_swarm_turn( last_runner_check = Instant::now(); if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { reconciliation.push("external-runner".to_string()); + observer.close_response_line(output)?; return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); } } @@ -762,6 +820,7 @@ fn wait_for_swarm_turn( let since = stable_since.get_or_insert_with(Instant::now); if since.elapsed() >= settle_window { if recovery_scan_required { + observer.close_response_line(output)?; writeln!( output, "[收束] Runtime 已空闲,检查待发布的 receipt / join。" @@ -781,6 +840,7 @@ fn wait_for_swarm_turn( session_id, previous_message_count, output, + observer, )?; return Ok(SwarmTurnOutcome::Settled); } @@ -790,6 +850,7 @@ fn wait_for_swarm_turn( let Some(command) = parse_swarm_chat_input(&line) else { continue; }; + observer.close_response_line(output)?; match command { SwarmChatInput::Quit => return Ok(SwarmTurnOutcome::Quit), SwarmChatInput::Help => print_swarm_chat_help(output)?, @@ -834,9 +895,13 @@ fn wait_for_swarm_turn( } } Ok(SwarmInputEvent::Eof) | Err(RecvTimeoutError::Disconnected) => { - return Ok(SwarmTurnOutcome::Quit) + observer.close_response_line(output)?; + return Ok(SwarmTurnOutcome::Quit); + } + Ok(SwarmInputEvent::Error(error)) => { + observer.close_response_line(output)?; + return Err(format!("读取终端输入失败:{error}")); } - Ok(SwarmInputEvent::Error(error)) => return Err(format!("读取终端输入失败:{error}")), Err(RecvTimeoutError::Timeout) => {} } } @@ -872,6 +937,7 @@ fn print_new_parent_reply( session_id: &str, previous_message_count: usize, output: &mut W, + observer: &mut SwarmRuntimeObserver, ) -> Result<(), String> { let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; @@ -881,14 +947,33 @@ fn print_new_parent_reply( .skip(previous_message_count) .filter(|message| message.role == "assistant") .next_back(); - if let Some(reply) = reply { - writeln!(output, "\nAgent> {}", reply.content) - .map_err(|error| format!("写入终端失败:{error}"))?; + observer.close_response_line(output)?; + print_settled_parent_reply( + parent_agent_id, + session_id, + reply.map(|message| message.content.as_str()), + observer, + output, + ) +} + +fn print_settled_parent_reply( + parent_agent_id: &str, + session_id: &str, + reply: Option<&str>, + observer: &SwarmRuntimeObserver, + output: &mut W, +) -> Result<(), String> { + let Some(reply) = reply else { + return writeln!(output, "[本轮结束] 父 Agent 未产生新的最终回复。") + .map_err(|error| format!("写入终端失败:{error}")); + }; + if observer.parent_reply_was_fully_streamed(parent_agent_id, session_id, reply) { + writeln!(output, "[本轮结束] 父 Agent 回复已完整流式输出。") + .map_err(|error| format!("写入终端失败:{error}")) } else { - writeln!(output, "[本轮结束] 父 Agent 未产生新的最终回复。") - .map_err(|error| format!("写入终端失败:{error}"))?; + writeln!(output, "\nAgent> {reply}").map_err(|error| format!("写入终端失败:{error}")) } - Ok(()) } fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Result<(), String> { @@ -903,6 +988,84 @@ fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Re Ok(()) } +impl SwarmResponseStreamIdentity { + fn from_stream(stream: &AgentRuntimeResponseStream) -> Self { + Self { + task_id: stream.task_id.clone(), + session_id: stream.session_id.clone(), + run_id: stream.run_id.clone(), + request_slot: stream.request_slot.clone(), + applied_steer_cursor: stream.applied_steer_cursor, + response_revision: stream.response_revision, + } + } +} + +impl SwarmResponseStreamCursor { + fn seeded(stream: &AgentRuntimeResponseStream) -> Self { + Self { + identity: SwarmResponseStreamIdentity::from_stream(stream), + session_id: stream.session_id.clone(), + sequence: stream.sequence, + status: stream.status.clone(), + accumulated_text: stream.accumulated_text.clone(), + printed_accumulated_text: stream.accumulated_text.is_empty().then(String::new), + connected: true, + rejected_snapshot: None, + } + } + + fn fresh(stream: &AgentRuntimeResponseStream) -> Self { + Self { + identity: SwarmResponseStreamIdentity::from_stream(stream), + session_id: stream.session_id.clone(), + sequence: stream.sequence, + status: stream.status.clone(), + accumulated_text: stream.accumulated_text.clone(), + printed_accumulated_text: None, + connected: true, + rejected_snapshot: None, + } + } +} + +impl SwarmRejectedResponseStreamSnapshot { + fn from_stream(stream: &AgentRuntimeResponseStream) -> Self { + Self { + session_id: stream.session_id.clone(), + sequence: stream.sequence, + status: stream.status.clone(), + accumulated_text: stream.accumulated_text.clone(), + } + } +} + +fn swarm_response_stream_is_printable(stream: &AgentRuntimeResponseStream) -> bool { + matches!( + stream.status.as_str(), + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY + ) +} + +fn swarm_response_stream_identity_reset_reason( + previous: &SwarmResponseStreamIdentity, + current: &SwarmResponseStreamIdentity, +) -> &'static str { + if previous.run_id != current.run_id { + "new-run" + } else if previous.session_id != current.session_id { + "new-session" + } else if previous.task_id != current.task_id { + "new-task" + } else if previous.applied_steer_cursor != current.applied_steer_cursor { + "new-steer-cursor" + } else if previous.request_slot != current.request_slot { + "new-request-slot" + } else { + "new-response-revision" + } +} + impl SwarmRuntimeObserver { fn seed(root: &Path) -> Result { let mut observer = Self::default(); @@ -911,6 +1074,12 @@ impl SwarmRuntimeObserver { runtime.state.agent_id.clone(), runtime_state_signature(&runtime.state, &runtime.task_queue), ); + if let Some(stream) = runtime.response_stream.as_ref() { + observer.response_streams.insert( + runtime.state.agent_id.clone(), + SwarmResponseStreamCursor::seeded(stream), + ); + } for event in runtime.recent_events { observer.seen_events.insert(runtime_event_key(&event)); } @@ -932,6 +1101,7 @@ impl SwarmRuntimeObserver { let signature = runtime_state_signature(&runtime.state, &runtime.task_queue); if self.state_signatures.get(&runtime.state.agent_id) != Some(&signature) { changed = true; + self.close_response_line(output)?; self.state_signatures .insert(runtime.state.agent_id.clone(), signature); print_runtime_state(&runtime.state, &runtime.task_queue, output)?; @@ -940,6 +1110,7 @@ impl SwarmRuntimeObserver { for event in &runtime.recent_events { if self.seen_events.insert(runtime_event_key(event)) { changed = true; + self.close_response_line(output)?; writeln!( output, "[事件] {} {} {}/{} {}{}", @@ -957,10 +1128,248 @@ impl SwarmRuntimeObserver { .map_err(|error| format!("写入终端失败:{error}"))?; } } + changed |= self.observe_response_stream( + &runtime.state.agent_id, + runtime.response_stream.as_ref(), + output, + )?; } Ok(changed) } + fn observe_response_stream( + &mut self, + agent_id: &str, + stream: Option<&AgentRuntimeResponseStream>, + output: &mut W, + ) -> Result { + let previous = self.response_streams.remove(agent_id); + let Some(stream) = stream else { + let Some(mut cursor) = previous else { + return Ok(false); + }; + let changed = cursor.connected; + if changed { + cursor.connected = false; + if self.response_line_matches(agent_id, &cursor.identity) { + self.close_response_line(output)?; + } + } + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(changed); + }; + + let printable = swarm_response_stream_is_printable(stream); + let Some(previous) = previous else { + let reason = if stream.sequence == 0 && stream.accumulated_text.is_empty() { + "new-stream" + } else { + "reconnect" + }; + self.print_response_stream_reset(agent_id, stream, reason, output)?; + let mut cursor = SwarmResponseStreamCursor::fresh(stream); + if printable { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(true); + }; + + let identity = SwarmResponseStreamIdentity::from_stream(stream); + if previous.identity != identity { + let reason = swarm_response_stream_identity_reset_reason(&previous.identity, &identity); + self.print_response_stream_reset(agent_id, stream, reason, output)?; + let mut cursor = SwarmResponseStreamCursor::fresh(stream); + if printable { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(true); + } + + let exact_snapshot = previous.session_id == stream.session_id + && previous.sequence == stream.sequence + && previous.status == stream.status + && previous.accumulated_text == stream.accumulated_text; + let unchanged = previous.connected && exact_snapshot; + if unchanged { + self.response_streams.insert(agent_id.to_string(), previous); + return Ok(false); + } + + let non_monotonic_reason = if previous.session_id != stream.session_id { + Some("identity-conflict") + } else if stream.sequence < previous.sequence { + Some("sequence-rollback") + } else if stream.sequence == previous.sequence && !exact_snapshot { + Some("sequence-conflict") + } else { + None + }; + if let Some(reason) = non_monotonic_reason { + let rejected = SwarmRejectedResponseStreamSnapshot::from_stream(stream); + if previous.rejected_snapshot.as_ref() == Some(&rejected) { + self.response_streams.insert(agent_id.to_string(), previous); + return Ok(false); + } + self.print_response_stream_reset(agent_id, stream, reason, output)?; + let mut cursor = previous; + cursor.connected = false; + cursor.rejected_snapshot = Some(rejected); + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(true); + } + + let prefix_continuation = stream + .accumulated_text + .strip_prefix(&previous.accumulated_text); + let reset_reason = if !previous.connected { + Some("reconnect") + } else if prefix_continuation.is_none() { + Some("non-prefix-correction") + } else { + None + }; + if let Some(reason) = reset_reason { + self.print_response_stream_reset(agent_id, stream, reason, output)?; + } + + let mut cursor = SwarmResponseStreamCursor::fresh(stream); + if printable { + let previous_printed = previous.printed_accumulated_text.as_deref(); + let reset_requires_full = reset_reason.is_some_and(|reason| reason != "reconnect") + && previous_printed != Some(stream.accumulated_text.as_str()); + if previous_printed == Some(stream.accumulated_text.as_str()) { + cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); + } else if reset_requires_full { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } else if let Some(suffix) = prefix_continuation + .filter(|_| previous_printed == Some(previous.accumulated_text.as_str())) + { + self.write_response_stream_chunk(agent_id, &identity, suffix, output)?; + cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); + } else { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } + } else { + cursor.printed_accumulated_text = previous + .printed_accumulated_text + .filter(|printed| printed == &stream.accumulated_text); + if self.response_line_matches(agent_id, &identity) { + self.close_response_line(output)?; + } + } + self.response_streams.insert(agent_id.to_string(), cursor); + Ok(true) + } + + fn print_response_stream_full( + &mut self, + agent_id: &str, + stream: &AgentRuntimeResponseStream, + cursor: &mut SwarmResponseStreamCursor, + output: &mut W, + ) -> Result<(), String> { + self.write_response_stream_chunk( + agent_id, + &cursor.identity, + &stream.accumulated_text, + output, + )?; + cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); + Ok(()) + } + + fn print_response_stream_reset( + &mut self, + agent_id: &str, + stream: &AgentRuntimeResponseStream, + reason: &str, + output: &mut W, + ) -> Result<(), String> { + self.close_response_line(output)?; + writeln!( + output, + "[回复流重置] agent={} run={} requestSlot={} revision={} sequence={} status={} chars={} reason={}", + agent_id, + stream.run_id, + stream.request_slot, + stream.response_revision, + stream.sequence, + stream.status, + stream.accumulated_text.chars().count(), + reason + ) + .map_err(|error| format!("写入终端失败:{error}")) + } + + fn write_response_stream_chunk( + &mut self, + agent_id: &str, + identity: &SwarmResponseStreamIdentity, + chunk: &str, + output: &mut W, + ) -> Result<(), String> { + if chunk.is_empty() { + return Ok(()); + } + if !self.response_line_matches(agent_id, identity) { + self.close_response_line(output)?; + write!(output, "Agent[{agent_id}]> {chunk}") + .map_err(|error| format!("写入终端失败:{error}"))?; + self.open_response_line = Some(SwarmResponseStreamLine { + agent_id: agent_id.to_string(), + identity: identity.clone(), + }); + } else { + write!(output, "{chunk}").map_err(|error| format!("写入终端失败:{error}"))?; + } + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}")) + } + + fn response_line_matches( + &self, + agent_id: &str, + identity: &SwarmResponseStreamIdentity, + ) -> bool { + self.open_response_line + .as_ref() + .is_some_and(|line| line.agent_id == agent_id && line.identity == *identity) + } + + fn close_response_line(&mut self, output: &mut W) -> Result<(), String> { + if self.open_response_line.take().is_some() { + writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + } + Ok(()) + } + + fn parent_reply_was_fully_streamed( + &self, + parent_agent_id: &str, + session_id: &str, + reply: &str, + ) -> bool { + self.response_streams + .get(parent_agent_id) + .is_some_and(|cursor| { + cursor.session_id == session_id + && cursor.accumulated_text == reply + && cursor.printed_accumulated_text.as_deref() == Some(reply) + && matches!( + cursor.status.as_str(), + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ) + }) + } + fn resolve_confirmations( &mut self, root: &Path, @@ -980,6 +1389,7 @@ impl SwarmRuntimeObserver { if self.handled_confirmations.contains(&key) { continue; } + self.close_response_line(output)?; writeln!( output, "\n[待确认] agent={} run={} action={} tool={}", @@ -1229,9 +1639,43 @@ mod tests { task_queue, recent_events: Vec::new(), recent_tasks: Vec::new(), + response_stream: None, } } + fn response_stream( + request_slot: &str, + response_revision: u64, + sequence: u64, + status: &str, + accumulated_text: &str, + ) -> AgentRuntimeResponseStream { + AgentRuntimeResponseStream { + schema_version: "game-creator-runtime-response-stream.v1".to_string(), + agent_id: "code-prototype".to_string(), + task_id: "code-prototype".to_string(), + session_id: "session-test".to_string(), + run_id: "run-test".to_string(), + request_kind: "final-reply".to_string(), + request_slot: request_slot.to_string(), + applied_steer_cursor: 0, + response_revision, + sequence, + status: status.to_string(), + accumulated_text: accumulated_text.to_string(), + finish_reason: None, + started_at: 100, + updated_at: 100 + sequence, + } + } + + fn runtime_with_response_stream(stream: AgentRuntimeResponseStream) -> AgentRuntimeResult { + let mut snapshot = runtime("running", "response", 0); + snapshot.state.session_id = stream.session_id.clone(); + snapshot.response_stream = Some(stream); + snapshot + } + #[test] fn parses_chat_commands_without_stealing_normal_messages() { assert_eq!(parse_swarm_chat_input(" "), None); @@ -1477,6 +1921,292 @@ mod tests { assert!(!output.contains("PRIVATE_STEP_DETAIL")); } + #[test] + fn response_stream_prints_only_monotonic_utf8_suffixes() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 7, + 0, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "", + )); + + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe empty response stream")); + snapshot.response_stream = Some(response_stream( + "slot-1", + 7, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "你", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe first utf-8 suffix")); + snapshot.response_stream = Some(response_stream( + "slot-1", + 7, + 2, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "你好🙂", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe second utf-8 suffix")); + assert!(!observer + .print_changes(&[snapshot], &mut output) + .expect("ignore duplicate snapshot")); + observer + .close_response_line(&mut output) + .expect("close response line"); + + let output = String::from_utf8(output).expect("stream output is utf-8"); + assert!(output.contains("Agent[code-prototype]> 你好🙂")); + assert_eq!(output.matches("Agent[code-prototype]>").count(), 1); + assert!(!output.contains("你你好")); + } + + #[test] + fn response_stream_resets_for_non_prefix_and_new_request_slot() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 9, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "旧稿", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe initial stream"); + + snapshot.response_stream = Some(response_stream( + "slot-1", + 9, + 2, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "修正版", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe non-prefix correction"); + snapshot.response_stream = Some(response_stream( + "slot-2", + 9, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "最终版", + )); + observer + .print_changes(&[snapshot], &mut output) + .expect("observe new request slot"); + observer + .close_response_line(&mut output) + .expect("close response line"); + + let output = String::from_utf8(output).expect("stream output is utf-8"); + assert!(output.contains("reason=non-prefix-correction")); + assert!(output.contains("reason=new-request-slot")); + assert_eq!(output.matches("旧稿").count(), 1); + assert_eq!(output.matches("修正版").count(), 1); + assert_eq!(output.matches("最终版").count(), 1); + } + + #[test] + fn response_stream_resets_sequence_for_same_run_steer_cursor() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut initial = response_stream( + "slot-1", + 9, + 4, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "纠偏前回复", + ); + initial.applied_steer_cursor = 1; + observer + .print_changes(&[runtime_with_response_stream(initial)], &mut output) + .expect("observe pre-steer stream"); + + let mut steered = response_stream( + "slot-1", + 9, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "纠偏后回复", + ); + steered.applied_steer_cursor = 2; + observer + .print_changes(&[runtime_with_response_stream(steered)], &mut output) + .expect("observe same-run stream after steer"); + observer + .close_response_line(&mut output) + .expect("close steered response line"); + + let output = String::from_utf8(output).expect("steer output is utf-8"); + assert!(output.contains("reason=new-steer-cursor")); + assert!(!output.contains("reason=sequence-rollback")); + assert_eq!(output.matches("纠偏前回复").count(), 1); + assert_eq!(output.matches("纠偏后回复").count(), 1); + } + + #[test] + fn response_stream_reconnects_without_repeating_body_and_rejects_sequence_rollback() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 11, + 3, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "已输出", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe initial stream"); + + snapshot.response_stream = None; + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe disconnect")); + snapshot.response_stream = Some(response_stream( + "slot-1", + 11, + 3, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "已输出", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe reconnect")); + + snapshot.response_stream = Some(response_stream( + "slot-1", + 11, + 2, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "回退正文", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("report sequence rollback")); + assert!(!observer + .print_changes(&[snapshot], &mut output) + .expect("deduplicate repeated rollback")); + + let cursor = observer + .response_streams + .get("code-prototype") + .expect("response cursor"); + assert_eq!(cursor.sequence, 3); + assert_eq!(cursor.accumulated_text, "已输出"); + assert_eq!(cursor.printed_accumulated_text.as_deref(), Some("已输出")); + + let recovered = runtime_with_response_stream(response_stream( + "slot-1", + 11, + 4, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "已输出继续", + )); + assert!(observer + .print_changes(&[recovered], &mut output) + .expect("resume from accepted high-water mark")); + observer + .close_response_line(&mut output) + .expect("close recovered response line"); + + let output = String::from_utf8(output).expect("stream output is utf-8"); + assert!(output.contains("reason=reconnect")); + assert!(output.contains("reason=sequence-rollback")); + assert_eq!(output.matches("已输出").count(), 1); + assert_eq!(output.matches("继续").count(), 1); + assert!(!output.contains("回退正文")); + } + + #[test] + fn settled_parent_reply_is_not_repeated_after_complete_stream() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 13, + 4, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "权威最终回复", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe complete stream"); + snapshot.response_stream = Some(response_stream( + "slot-1", + 13, + 5, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED, + "权威最终回复", + )); + observer + .print_changes(&[snapshot], &mut output) + .expect("observe committed stream without printing body"); + observer + .close_response_line(&mut output) + .expect("close response line"); + print_settled_parent_reply( + "code-prototype", + "session-test", + Some("权威最终回复"), + &observer, + &mut output, + ) + .expect("settle streamed reply"); + + let output = String::from_utf8(output).expect("settle output is utf-8"); + assert_eq!(output.matches("权威最终回复").count(), 1); + assert!(output.contains("父 Agent 回复已完整流式输出")); + + let mut fallback = Vec::new(); + print_settled_parent_reply( + "code-prototype", + "session-test", + Some("未流过的权威回复"), + &SwarmRuntimeObserver::default(), + &mut fallback, + ) + .expect("print authoritative fallback"); + let fallback = String::from_utf8(fallback).expect("fallback output is utf-8"); + assert!(fallback.contains("Agent> 未流过的权威回复")); + } + + #[test] + fn response_stream_status_reports_only_status_sequence_and_char_count() { + let body = "PRIVATE_RESPONSE_BODY"; + let stream = response_stream( + "slot-private", + 17, + 8, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + body, + ); + let mut output = Vec::new(); + print_runtime_response_stream_status(Some(&stream), &mut output) + .expect("print response stream status"); + let output = String::from_utf8(output).expect("status output is utf-8"); + + assert_eq!( + output.trim(), + format!( + "[回复流] status=ready sequence=8 chars={}", + body.chars().count() + ) + ); + assert!(!output.contains(body)); + assert!(!output.contains("slot-private")); + } + #[test] fn input_channel_preserves_lines_and_eof() { let (tx, rx) = mpsc::channel(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 6ada8905d..f1ea629cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -981,6 +981,147 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState runtime } +fn wait_for_response_stream_status( + root: &Path, + agent_id: &str, + run_id: &str, + status: &str, + minimum_sequence: u64, +) -> AgentRuntimeResponseStream { + let mut last_stream = None; + for _ in 0..250 { + match read_game_creator_agent_runtime_response_stream_at(root, agent_id, run_id) { + Ok(Some(stream)) => { + if stream.status == status && stream.sequence >= minimum_sequence { + return stream; + } + last_stream = Some(stream); + } + Ok(None) => {} + Err(error) => panic!("read response stream sidecar while waiting: {error}"), + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!( + "response stream did not reach status={status} sequence>={minimum_sequence}: {last_stream:?}" + ); +} + +fn assert_response_stream_public_surfaces_exclude( + root: &Path, + agent_id: &str, + forbidden_texts: &[&str], +) { + for path in [ + game_creator_agent_runtime_event_path(root, agent_id), + root.join(".agent/agent.db"), + root.join(".agent/activity.jsonl"), + root.join(".agent/output.jsonl"), + ] { + if !path.exists() { + continue; + } + let content = fs::read_to_string(&path).expect("read response stream public surface"); + for forbidden in forbidden_texts { + assert!( + !content.contains(forbidden), + "{} leaked response text {forbidden:?}", + path.display() + ); + } + } + + let receipt_records = read_agent_db_records_for_test(root) + .into_iter() + .filter(|record| { + record["recordType"] + .as_str() + .is_some_and(|record_type| record_type.contains("receipt")) + }) + .collect::>(); + let receipts = serde_json::to_string(&receipt_records).expect("serialize Runtime receipts"); + for forbidden in forbidden_texts { + assert!( + !receipts.contains(forbidden), + "Runtime receipt leaked response text {forbidden:?}" + ); + } +} + +fn assert_response_stream_completion_event_details( + root: &Path, + agent_id: &str, + run_id: &str, + canonical_response: &str, +) { + let expected_detail = format!( + "responseSha256={:x} responseChars={}", + Sha256::digest(canonical_response.as_bytes()), + canonical_response.chars().count() + ); + let runtime = read_game_creator_agent_runtime_at(root, agent_id) + .expect("read response stream completion events"); + for event_type in ["turn.completed", "response"] { + let matching = runtime + .recent_events + .iter() + .filter(|event| event.run_id == run_id && event.event_type == event_type) + .collect::>(); + assert_eq!( + matching.len(), + 1, + "expected exactly one {event_type} event for {run_id}" + ); + assert_eq!( + matching[0].detail.as_deref(), + Some(expected_detail.as_str()) + ); + assert!(!matching[0] + .detail + .as_deref() + .unwrap_or_default() + .contains(canonical_response)); + } +} + +fn assert_response_stream_provider_lifecycles(root: &Path, run_id: &str, request_kinds: &[&str]) { + let records = read_agent_db_records_for_test(root); + let lifecycle_records = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle_records.len(), request_kinds.len() * 2); + let mut request_ids = BTreeSet::new(); + for request_kind in request_kinds { + let records = lifecycle_records + .iter() + .filter(|record| record["requestKind"] == *request_kind) + .collect::>(); + assert_eq!(records.len(), 2, "lifecycle count for {request_kind}"); + assert_eq!(records[0]["status"], "started"); + assert_eq!(records[1]["status"], "completed"); + assert_eq!(records[0]["requestId"], records[1]["requestId"]); + request_ids.insert( + records[0]["requestId"] + .as_str() + .expect("Provider lifecycle requestId") + .to_string(), + ); + } + assert_eq!(request_ids.len(), request_kinds.len()); + let protocol_records = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocol_records.len(), 1); + assert_eq!(protocol_records[0]["protocol"], "text_json"); +} + fn wait_to_acquire_agent_runtime_lock(root: &Path, agent_id: &str) -> AgentRuntimeTaskLock { for _ in 0..250 { if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id) @@ -1009,6 +1150,21 @@ fn wait_for_agent_runtime_confirmation(root: &Path, agent_id: &str) -> AgentRunt runtime } +fn wait_for_agent_db_record_type(root: &Path, record_type: &str) -> Vec { + let mut records = read_agent_db_records_for_test(root); + for _ in 0..250 { + if records + .iter() + .any(|record| record["recordType"] == record_type) + { + return records; + } + std::thread::sleep(Duration::from_millis(10)); + records = read_agent_db_records_for_test(root); + } + records +} + fn write_agent_runtime_verification_fixture(root: &Path) -> &'static str { const CHECK_COMMAND: &str = r#"node -e "process.stdout.write('AGENT_RUNTIME_CURRENT_REVISION_OK')""#; @@ -2659,6 +2815,222 @@ fn spawn_mock_llm_stream_server_with_capture( base_url } +enum ResponseStreamMockFinalResponse { + Deltas(String, String), + Disconnect, +} + +struct ResponseStreamMockServer { + base_url: String, + first_delta_written: mpsc::Receiver<()>, + release_second_delta: mpsc::Sender<()>, + stop: mpsc::Sender<()>, + handle: std::thread::JoinHandle>, +} + +impl ResponseStreamMockServer { + fn stop_and_collect(self) -> Vec { + let _ = self.stop.send(()); + self.handle + .join() + .expect("response stream mock server join") + } +} + +fn spawn_response_stream_mock_llm_server( + api_kind: &str, + planning_response: String, + final_response: Option, +) -> ResponseStreamMockServer { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("response stream mock bind"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("response stream mock addr") + ); + let api_kind = api_kind.to_string(); + let (first_delta_sender, first_delta_written) = mpsc::channel(); + let (release_second_delta, release_second_delta_receiver) = mpsc::channel(); + let (stop, stop_receiver) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + let (mut planning_stream, _) = listener + .accept() + .expect("response stream planning request accept"); + let planning_request = read_mock_http_request(&mut planning_stream); + requests.push(planning_request); + let planning_body = match api_kind.as_str() { + "openai_responses" => serde_json::json!({ + "id": "resp_response_stream_planning", + "model": "response-stream-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }), + "openai_chat" => serde_json::json!({ + "id": "chatcmpl_response_stream_planning", + "model": "response-stream-model", + "choices": [{ + "message": { "content": planning_response }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }), + other => panic!("unsupported response stream mock api kind: {other}"), + } + .to_string(); + let planning_http_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ); + planning_stream + .write_all(planning_http_response.as_bytes()) + .expect("response stream planning response"); + + if let Some(final_response) = final_response { + listener + .set_nonblocking(true) + .expect("response stream mock listener nonblocking"); + let accept_deadline = std::time::Instant::now() + Duration::from_secs(10); + let (mut final_stream, _) = loop { + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < accept_deadline, + "response stream final request was not received" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("response stream final request accept failed: {error}"), + } + }; + final_stream + .set_nonblocking(false) + .expect("response stream final socket blocking"); + requests.push(read_mock_http_request(&mut final_stream)); + let ResponseStreamMockFinalResponse::Deltas(first_delta, second_delta) = final_response + else { + first_delta_sender + .send(()) + .expect("signal response stream final disconnect"); + release_second_delta_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("release response stream final disconnect"); + drop(final_stream); + return monitor_response_stream_extra_requests(&listener, stop_receiver, requests); + }; + let (first_event, remaining_events) = match api_kind.as_str() { + "openai_responses" => ( + format!( + "data: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": first_delta + }) + ), + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": second_delta + }), + serde_json::json!({ "type": "response.completed" }) + ), + ), + "openai_chat" => ( + format!( + "data: {}\n\n", + serde_json::json!({ + "choices": [{ "delta": { "content": first_delta } }] + }) + ), + format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + serde_json::json!({ + "choices": [{ "delta": { "content": second_delta } }] + }), + serde_json::json!({ + "choices": [{ "finish_reason": "stop" }] + }) + ), + ), + other => panic!("unsupported response stream mock api kind: {other}"), + }; + let response_body_len = first_event.len() + remaining_events.len(); + let response_headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {response_body_len}\r\nx-request-id: req_response_stream_final\r\nConnection: close\r\n\r\n" + ); + final_stream + .write_all(response_headers.as_bytes()) + .expect("response stream final headers"); + final_stream + .write_all(first_event.as_bytes()) + .expect("response stream first delta"); + final_stream + .flush() + .expect("flush response stream first delta"); + first_delta_sender + .send(()) + .expect("signal response stream first delta"); + release_second_delta_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("release response stream second delta"); + final_stream + .write_all(remaining_events.as_bytes()) + .expect("response stream remaining deltas"); + final_stream + .flush() + .expect("flush response stream remaining deltas"); + } + monitor_response_stream_extra_requests(&listener, stop_receiver, requests) + }); + ResponseStreamMockServer { + base_url, + first_delta_written, + release_second_delta, + stop, + handle, + } +} + +fn monitor_response_stream_extra_requests( + listener: &TcpListener, + stop_receiver: mpsc::Receiver<()>, + mut requests: Vec, +) -> Vec { + listener + .set_nonblocking(true) + .expect("response stream mock listener monitor mode"); + let monitor_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let stop_requested = stop_receiver.try_recv().is_ok(); + match listener.accept() { + Ok((mut unexpected_stream, _)) => { + unexpected_stream + .set_nonblocking(false) + .expect("unexpected response stream socket blocking"); + requests.push(read_mock_http_request(&mut unexpected_stream)); + let body = r#"{"error":"unexpected extra request"}"#; + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = unexpected_stream.write_all(response.as_bytes()); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if stop_requested || std::time::Instant::now() >= monitor_deadline { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("response stream extra request monitor failed: {error}"), + } + } + requests +} + fn spawn_mock_llm_stream_fallback_server( first_status_line: &'static str, first_content_type: &'static str, @@ -5205,7 +5577,7 @@ async fn background_agent_runtime_executes_native_function_tool_plan() { "baseUrl": {base_url:?}, "model": "design-runtime-model", "apiKind": "openai_chat", - "stream": true + "stream": false }} }} }}"# @@ -5265,6 +5637,578 @@ async fn background_agent_runtime_executes_native_function_tool_plan() { fs::remove_dir_all(root).ok(); } +fn run_response_stream_distinct_final_reply_case(api_kind: &str, case_name: &str) { + let root = unique_project_path(); + init_local_game_project_at( + &root, + &format!("project-response-stream-{case_name}"), + "后台最终回复真流式项目", + ) + .expect("response stream project init"); + let planning_fallback = format!("规划阶段备用回复【{case_name}】不得提前提交。"); + let first_delta = format!("第一段公开中文回复【{case_name}】"); + let second_delta = ",第二段完成。".to_string(); + let canonical_response = format!("{first_delta}{second_delta}"); + let mock = spawn_response_stream_mock_llm_server( + api_kind, + final_tool_plan_response(&planning_fallback), + Some(ResponseStreamMockFinalResponse::Deltas( + first_delta.clone(), + second_delta.clone(), + )), + ); + let base_url = mock.base_url.clone(); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "response-stream-key", + "baseUrl": {base_url:?}, + "model": "response-stream-model", + "apiKind": {api_kind:?}, + "stream": true, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = format!("response-stream-{case_name}-run"); + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "生成两段公开中文最终回复", + &run_id, + ) + .expect("start streamed final reply task"); + let session_id = started.state.session_id.clone(); + + mock.first_delta_written + .recv_timeout(Duration::from_secs(5)) + .expect("first public final-reply delta"); + let streaming = + wait_for_response_stream_status(&root, "design-director", &run_id, "streaming", 1); + assert_eq!(streaming.accumulated_text, first_delta); + let during_stream = + read_game_creator_agent_runtime_for_session_at(&root, "design-director", Some(&session_id)) + .expect("read Runtime during first final-reply delta"); + assert_eq!(during_stream.state.status, "running"); + assert_eq!(during_stream.state.phase, "response"); + assert_eq!( + during_stream + .response_stream + .as_ref() + .map(|stream| stream.sequence), + Some(streaming.sequence) + ); + let before_release = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read conversation before second final-reply delta"); + assert_eq!( + before_release + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 0 + ); + + mock.release_second_delta + .send(()) + .expect("release second public final-reply delta"); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.status, "idle"); + assert_eq!(completed.phase, "completed"); + assert_eq!( + completed.last_response.as_deref(), + Some(canonical_response.as_str()) + ); + let committed = wait_for_response_stream_status( + &root, + "design-director", + &run_id, + "committed", + streaming.sequence.saturating_add(1), + ); + assert_eq!(committed.accumulated_text, canonical_response); + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read committed streamed conversation"); + let assistants = conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(assistants, vec![canonical_response.as_str()]); + + let requests = mock.stop_and_collect(); + assert_eq!(requests.len(), 2, "{api_kind} physical request count"); + let expected_route = if api_kind == "openai_responses" { + "POST /responses HTTP/1.1" + } else { + "POST /chat/completions HTTP/1.1" + }; + assert!(requests + .iter() + .all(|request| request.contains(expected_route))); + let planning_request = mock_http_request_json(&requests[0]); + let final_request = mock_http_request_json(&requests[1]); + assert_eq!(planning_request["stream"], Value::Bool(false)); + assert_eq!(final_request["stream"], Value::Bool(true)); + assert!(requests[0].contains("submit_agent_tool_plan")); + assert!(!requests[1].contains("submit_agent_tool_plan")); + assert_response_stream_provider_lifecycles(&root, &run_id, &["tool-plan", "final-reply"]); + assert_response_stream_completion_event_details( + &root, + "design-director", + &run_id, + &canonical_response, + ); + assert_response_stream_public_surfaces_exclude( + &root, + "design-director", + &[&first_delta, &second_delta, &canonical_response], + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn response_stream_uses_distinct_streamed_final_reply_for_responses_and_chat() { + run_response_stream_distinct_final_reply_case("openai_responses", "responses"); + run_response_stream_distinct_final_reply_case("openai_chat", "chat"); +} + +#[tokio::test] +async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-response-stream-direct", "非流最终回复项目") + .expect("direct response project init"); + let direct_response = "非流配置直接采用 planning response,且只发起一次请求。"; + let mock = spawn_response_stream_mock_llm_server( + "openai_responses", + final_tool_plan_response(direct_response), + None, + ); + let base_url = mock.base_url.clone(); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "response-stream-direct-key", + "baseUrl": {base_url:?}, + "model": "response-stream-direct-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "response-stream-disabled-direct-run"; + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "直接返回非流 planning response", + run_id, + ) + .expect("start direct planning response task"); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.status, "idle"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.last_response.as_deref(), Some(direct_response)); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&started.state.session_id), + ) + .expect("read direct planning response conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![direct_response] + ); + + let requests = mock.stop_and_collect(); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains("POST /responses HTTP/1.1")); + assert_eq!( + mock_http_request_json(&requests[0])["stream"], + Value::Bool(false) + ); + assert_response_stream_provider_lifecycles(&root, run_id, &["tool-plan"]); + assert_response_stream_completion_event_details( + &root, + "design-director", + run_id, + direct_response, + ); + assert_response_stream_public_surfaces_exclude(&root, "design-director", &[direct_response]); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn response_stream_private_process_output_is_never_published_or_committed_raw() { + const SAFE_RESPONSE: &str = "持久进程交互已完成,私有进程输出已省略。"; + const PRIVATE_OUTPUT: &str = "PRIVATE_PROCESS_OUTPUT_不可进入回复流"; + + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-response-stream-private-process", + "持久进程安全流项目", + ) + .expect("private process response stream project init"); + let planning_fallback = format!("planning fallback 误回显:{PRIVATE_OUTPUT}"); + let first_delta = format!("模型原始第一段误回显:{PRIVATE_OUTPUT}"); + let second_delta = ";模型原始第二段也不得公开。".to_string(); + let raw_provider_response = format!("{first_delta}{second_delta}"); + let mock = spawn_response_stream_mock_llm_server( + "openai_responses", + final_tool_plan_response(&planning_fallback), + Some(ResponseStreamMockFinalResponse::Deltas( + first_delta.clone(), + second_delta.clone(), + )), + ); + let base_url = mock.base_url.clone(); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "response-stream-private-key", + "baseUrl": {base_url:?}, + "model": "response-stream-private-model", + "apiKind": "openai_responses", + "stream": true, + "maxRetries": 0 + }} + }} +}}"# + )); + let task = "用已有 command.poll 私有观察生成安全最终回复"; + let run_id = "response-stream-private-process-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + task, + run_id, + "agent-background-task", + "恢复私有进程观察", + Vec::new(), + ) + .expect("start private process response Runtime"); + let session_id = state.session_id.clone(); + let observation = AgentRuntimeToolObservation { + tool: "command.poll".to_string(), + status: "ok".to_string(), + summary: "进程会话已退出,本页读取私有输出".to_string(), + detail: Some( + serde_json::json!({ + "processId": "proc-response-stream-private-output", + "status": "exited", + "cursor": "v1:proc-response-stream-private-output:0", + "nextCursor": "v1:proc-response-stream-private-output:1", + "hasMore": false, + "stdinOpen": false, + "exitCode": 0, + "signal": null, + "outputBytes": PRIVATE_OUTPUT.len(), + "outputSha256": format!("{:x}", Sha256::digest(PRIVATE_OUTPUT.as_bytes())), + "sourceChanged": false, + "needsReconciliation": false, + "revisionAdvanced": false, + "output": PRIVATE_OUTPUT + }) + .to_string(), + ), + }; + let plan = AgentRuntimeToolPlan::default(); + let context_tracker = AgentRuntimeContextWindowTracker::default(); + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + task, + &plan, + std::slice::from_ref(&observation), + 0, + &context_tracker, + ) + .expect("build private process continuation"); + assert!(bundle.observations[0] + .detail + .as_deref() + .is_some_and(|detail| detail.contains(PRIVATE_OUTPUT))); + let continuation = continuation_from_game_creator_agent_runtime_context_bundle(bundle); + let runtime_root = root.clone(); + let runtime_thread = std::thread::spawn(move || { + tauri::async_runtime::block_on(run_game_creator_agent_background_task_with_context( + runtime_root, + "code-prototype".to_string(), + task.to_string(), + state, + continuation, + )) + }); + + mock.first_delta_written + .recv_timeout(Duration::from_secs(5)) + .expect("private Provider first raw delta"); + let streaming = + wait_for_response_stream_status(&root, "code-prototype", run_id, "streaming", 0); + assert_eq!(streaming.sequence, 0); + assert!(streaming.accumulated_text.is_empty()); + for _ in 0..20 { + let snapshot = + read_game_creator_agent_runtime_response_stream_at(&root, "code-prototype", run_id) + .expect("read suppressed private response stream") + .expect("suppressed private response stream exists"); + assert_eq!(snapshot.status, "streaming"); + assert_eq!(snapshot.sequence, 0); + assert!(!snapshot.accumulated_text.contains(PRIVATE_OUTPUT)); + std::thread::sleep(Duration::from_millis(5)); + } + let during_stream = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read Runtime during suppressed private stream"); + assert_eq!(during_stream.state.phase, "response"); + assert_eq!( + read_local_conversation_for_session_at(&root, Some("code-prototype"), Some(&session_id),) + .expect("read private stream conversation before ready") + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 0 + ); + + let finalization_lock = + acquire_project_write_lock(&root, "test.response_stream.private_process_ready_boundary") + .expect("hold private response finalization boundary"); + mock.release_second_delta + .send(()) + .expect("release remaining private Provider output"); + let ready = wait_for_response_stream_status(&root, "code-prototype", run_id, "ready", 1); + assert_eq!(ready.sequence, 1); + assert_eq!(ready.accumulated_text, SAFE_RESPONSE); + assert!(!ready.accumulated_text.contains(PRIVATE_OUTPUT)); + assert_eq!( + read_local_conversation_for_session_at(&root, Some("code-prototype"), Some(&session_id),) + .expect("read private stream conversation while ready") + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 0 + ); + drop(finalization_lock); + + let outcome = runtime_thread + .join() + .expect("private response Runtime join"); + assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished)); + let completed = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.last_response.as_deref(), Some(SAFE_RESPONSE)); + let committed = + wait_for_response_stream_status(&root, "code-prototype", run_id, "committed", 2); + assert_eq!(committed.sequence, 2); + assert_eq!(committed.accumulated_text, SAFE_RESPONSE); + assert!(!committed.accumulated_text.contains(PRIVATE_OUTPUT)); + let conversation = + read_local_conversation_for_session_at(&root, Some("code-prototype"), Some(&session_id)) + .expect("read safe private process canonical conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![SAFE_RESPONSE] + ); + + let requests = mock.stop_and_collect(); + assert_eq!(requests.len(), 2); + assert_eq!( + mock_http_request_json(&requests[0])["stream"], + Value::Bool(false) + ); + assert_eq!( + mock_http_request_json(&requests[1])["stream"], + Value::Bool(true) + ); + assert_response_stream_provider_lifecycles(&root, run_id, &["tool-plan", "final-reply"]); + assert_response_stream_completion_event_details(&root, "code-prototype", run_id, SAFE_RESPONSE); + assert_response_stream_public_surfaces_exclude( + &root, + "code-prototype", + &[ + PRIVATE_OUTPUT, + &first_delta, + &second_delta, + &raw_provider_response, + SAFE_RESPONSE, + ], + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fallback() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-response-stream-single-attempt", + "流式失败单次请求项目", + ) + .expect("single-attempt response stream project init"); + let planning_fallback = "final stream 失败后只提交这条 planning fallback。"; + let mock = spawn_response_stream_mock_llm_server( + "openai_responses", + final_tool_plan_response(planning_fallback), + Some(ResponseStreamMockFinalResponse::Disconnect), + ); + let base_url = mock.base_url.clone(); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "response-stream-retry-key", + "baseUrl": {base_url:?}, + "model": "response-stream-retry-model", + "apiKind": "openai_responses", + "stream": true, + "maxRetries": 7, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "response-stream-final-single-attempt-run"; + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 final reply 失败不在 lifecycle 内重试", + run_id, + ) + .expect("start final stream single-attempt task"); + + mock.first_delta_written + .recv_timeout(Duration::from_secs(5)) + .expect("final stream request reached disconnect boundary"); + let finalization_lock = acquire_project_write_lock( + &root, + "test.response_stream.final_failure_fallback_ready_boundary", + ) + .expect("hold fallback finalization boundary"); + mock.release_second_delta + .send(()) + .expect("release final stream disconnect"); + let ready = wait_for_response_stream_status(&root, "design-director", run_id, "ready", 2); + assert_eq!(ready.accumulated_text, planning_fallback); + assert_eq!(ready.finish_reason.as_deref(), Some("fallback")); + assert_eq!( + read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&started.state.session_id), + ) + .expect("read fallback conversation while ready") + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 0 + ); + drop(finalization_lock); + + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.last_response.as_deref(), Some(planning_fallback)); + let committed = wait_for_response_stream_status( + &root, + "design-director", + run_id, + "committed", + ready.sequence.saturating_add(1), + ); + assert_eq!(committed.accumulated_text, planning_fallback); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&started.state.session_id), + ) + .expect("read committed fallback conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![planning_fallback] + ); + + let requests = mock.stop_and_collect(); + assert_eq!(requests.len(), 2, "final stream must not retry physically"); + assert_eq!( + mock_http_request_json(&requests[0])["stream"], + Value::Bool(false) + ); + assert_eq!( + mock_http_request_json(&requests[1])["stream"], + Value::Bool(true) + ); + let lifecycle_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + let planning_lifecycle = lifecycle_records + .iter() + .filter(|record| record["requestKind"] == "tool-plan") + .collect::>(); + assert_eq!(planning_lifecycle.len(), 2); + assert_eq!(planning_lifecycle[0]["status"], "started"); + assert_eq!(planning_lifecycle[1]["status"], "completed"); + assert_eq!( + planning_lifecycle[0]["requestId"], + planning_lifecycle[1]["requestId"] + ); + let final_lifecycle = lifecycle_records + .iter() + .filter(|record| record["requestKind"] == "final-reply") + .collect::>(); + assert_eq!(final_lifecycle.len(), 2); + assert_eq!(final_lifecycle[0]["status"], "started"); + assert_eq!(final_lifecycle[1]["status"], "failed"); + assert_eq!( + final_lifecycle[0]["requestId"], + final_lifecycle[1]["requestId"] + ); + assert_eq!( + final_lifecycle[0]["requestSlot"], + final_lifecycle[1]["requestSlot"] + ); + assert_response_stream_completion_event_details( + &root, + "design-director", + run_id, + planning_fallback, + ); + assert_response_stream_public_surfaces_exclude(&root, "design-director", &[planning_fallback]); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_marks_response_plan_step_failed_when_final_reply_fails() { let root = unique_project_path(); @@ -11558,8 +12502,10 @@ async fn background_agent_runtime_task_list_respects_project_policy() { .any(|task| task.run_id == "design-task-list-policy-run" && task.status == "waiting-for-confirmation" && task.phase == "waiting-for-confirmation")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation_required\"")); + let records = wait_for_agent_db_record_type(&root, "agent.runtime.tool_confirmation_required"); + assert!(records + .iter() + .any(|record| record["recordType"] == "agent.runtime.tool_confirmation_required")); fs::remove_dir_all(root).ok(); } @@ -11755,9 +12701,13 @@ async fn background_agent_runtime_task_create_respects_project_policy() { .expect("tasks") .iter() .all(|task| task["id"] != "design-blocked-task")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation_required\"")); - assert!(!agent_db.contains("\"recordType\":\"agent.runtime.task.create\"")); + let records = wait_for_agent_db_record_type(&root, "agent.runtime.tool_confirmation_required"); + assert!(records + .iter() + .any(|record| record["recordType"] == "agent.runtime.tool_confirmation_required")); + assert!(!records + .iter() + .any(|record| record["recordType"] == "agent.runtime.task.create")); fs::remove_dir_all(root).ok(); } @@ -12222,7 +13172,7 @@ async fn background_agent_runtime_limited_command_respects_project_policy() { async fn background_agent_runtime_can_verify_project_script() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - let check_command = r#"node -e "process.stdout.write('PROJECT_VERIFY_OK')""#; + let check_command = r#"node -e "process.stdout.write('PROJECT_VERIFY_OK:' + process.cwd())""#; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ @@ -12308,6 +13258,30 @@ async fn background_agent_runtime_can_verify_project_script() { let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"agent.runtime.project.verify\"")); assert!(agent_db.contains("\"script\":\"check\"")); + let verify_audit = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|record| record["recordType"] == "agent.runtime.project.verify") + .expect("project verify Agent DB audit"); + assert_eq!( + verify_audit["logPath"], + serde_json::Value::String(".agent/logs/command.log".to_string()) + ); + let project_root = root.to_string_lossy(); + for field in ["expectedCommand", "output"] { + let value = verify_audit[field].as_str().expect("string audit field"); + assert!( + !value.contains(project_root.as_ref()), + "{field} leaked project root: {value}" + ); + } + assert!( + verify_audit["output"] + .as_str() + .is_some_and(|output| output.contains("$PROJECT_ROOT")), + "{}", + verify_audit["output"] + ); fs::remove_dir_all(root).ok(); } @@ -37301,6 +38275,53 @@ async fn agent_runtime_terminal_lifecycle_failure_keeps_reconciliation_barrier() fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn agent_runtime_terminal_lifecycle_failure_stays_blocked_when_projection_also_fails() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture( + &root, + "provider-terminal-lifecycle-and-projection-failure-run", + ); + let request_root = root.clone(); + let state_path = root + .join(".agent/runtime/agents") + .join(format!("{}.json", state.agent_id)); + let error = await_game_creator_agent_runtime_provider_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "final-reply", + "test-terminal-lifecycle-and-projection-failure", + 0, + async move { + fs::write( + request_root.join(".agent/runtime/test-fail-next-agent-db-record"), + "agent.runtime.provider_request.lifecycle", + ) + .expect("inject Provider terminal lifecycle failure"); + fs::remove_file(&state_path).expect("remove Runtime state before projection"); + fs::create_dir(&state_path).expect("block reconciliation Runtime projection"); + Ok::<(), String>(()) + }, + ) + .await + .expect_err("terminal lifecycle failure must remain a hard barrier"); + + assert!(error.starts_with("provider-request-needs-reconciliation")); + let lifecycle = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == state.run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 1); + assert_eq!(lifecycle[0]["status"], "started"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_inflight_steer_discards_old_plan_without_new_task() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e35132e1c..10be87171 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -304,6 +304,8 @@ interface AgentRuntimeState { taskQueue?: AgentRuntimeTaskQueueSummary; allowedTools: string[]; toolPolicy?: AgentRuntimeToolPolicySnapshot; + appliedSteerCursor?: number; + queuedSteerCount?: number; lastResponse: string | null; error: string | null; updatedAt: number; @@ -406,6 +408,32 @@ interface AgentRuntimeResult { taskQueue?: AgentRuntimeTaskQueueSummary; recentEvents?: AgentRuntimeEventRecord[]; recentTasks?: AgentRuntimeTaskRecord[]; + responseStream?: AgentRuntimeResponseStream | null; +} + +type AgentRuntimeResponseStreamStatus = + | 'streaming' + | 'ready' + | 'committed' + | 'discarded' + | 'failed'; + +interface AgentRuntimeResponseStream { + schemaVersion: string; + agentId: string; + taskId: string; + sessionId: string; + runId: string; + requestKind: string; + requestSlot: string; + appliedSteerCursor: number; + responseRevision: number; + sequence: number; + status: AgentRuntimeResponseStreamStatus; + accumulatedText: string; + finishReason: string | null; + startedAt: number; + updatedAt: number; } interface AgentGoalRecord { @@ -1022,6 +1050,118 @@ function agentRuntimeStateFromResult( ); } +function normalizeProjectSupervisorResponseStream( + stream: AgentRuntimeResponseStream | null | undefined, + runtime: AgentRuntimeState, +) { + if (!stream) { + return null; + } + const integerFields = [ + stream.appliedSteerCursor, + stream.responseRevision, + stream.sequence, + stream.startedAt, + stream.updatedAt, + ]; + if ( + stream.schemaVersion !== 'game-creator-runtime-response-stream.v1' || + stream.agentId !== PROJECT_SUPERVISOR_AGENT_ID || + stream.agentId !== runtime.agentId || + stream.taskId !== runtime.taskId || + stream.sessionId !== runtime.sessionId || + stream.runId !== runtime.runId || + stream.requestKind !== 'final-reply' || + !stream.requestSlot.trim() || + integerFields.some( + (value) => + typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0, + ) || + stream.startedAt <= 0 || + stream.updatedAt < stream.startedAt || + typeof stream.accumulatedText !== 'string' || + Array.from(stream.accumulatedText).length > 32_000 || + /<\/?think>/i.test(stream.accumulatedText) + ) { + return null; + } + if ( + typeof runtime.appliedSteerCursor === 'number' && + stream.appliedSteerCursor !== runtime.appliedSteerCursor + ) { + return null; + } + if ((runtime.queuedSteerCount ?? 0) > 0) { + return null; + } + if ( + typeof runtime.loopIteration === 'number' && + Number.isSafeInteger(runtime.loopIteration) && + stream.requestSlot !== + `final-reply-loop-${runtime.loopIteration}-revision-${stream.responseRevision}` + ) { + return null; + } + if ( + (stream.status === 'streaming' || stream.status === 'ready') && + (runtime.status !== 'running' || + !['response', 'finalizing'].includes(runtime.phase)) + ) { + return null; + } + if (stream.status === 'ready' && !stream.accumulatedText.trim()) { + return null; + } + return stream; +} + +function sameProjectSupervisorResponseStream( + left: AgentRuntimeResponseStream, + right: AgentRuntimeResponseStream, +) { + return ( + left.agentId === right.agentId && + left.taskId === right.taskId && + left.sessionId === right.sessionId && + left.runId === right.runId && + left.requestKind === right.requestKind && + left.requestSlot === right.requestSlot && + left.appliedSteerCursor === right.appliedSteerCursor && + left.responseRevision === right.responseRevision + ); +} + +function mergeProjectSupervisorResponseStream( + current: AgentRuntimeResponseStream | null, + incoming: AgentRuntimeResponseStream | null | undefined, + runtime: AgentRuntimeState, +) { + const currentForRuntime = normalizeProjectSupervisorResponseStream( + current, + runtime, + ); + if (!incoming) { + return null; + } + const next = normalizeProjectSupervisorResponseStream(incoming, runtime); + if (!next) { + return currentForRuntime; + } + if (next.status !== 'streaming' && next.status !== 'ready') { + return null; + } + if ( + !currentForRuntime || + !sameProjectSupervisorResponseStream(currentForRuntime, next) + ) { + return next; + } + if (next.sequence <= currentForRuntime.sequence) { + return currentForRuntime; + } + return next; +} + function agentRuntimeWaitingOnFromPhase(phase: string) { switch (phase) { case 'planning': @@ -2259,6 +2399,9 @@ function projectSupervisorRuntimeStatusLabel( ) { return '执行'; } + if (runtime.phase === 'response' || runtime.phase === 'finalizing') { + return '回复中'; + } if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { return '已取消'; } @@ -15409,6 +15552,8 @@ export function App() { >(null); const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); + const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = + useState(null); const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = useState(''); const chatInputRef = useRef(null); @@ -15534,6 +15679,9 @@ export function App() { projectSupervisorSessionIdRef.current = projectSupervisorSessionId; const projectSupervisorRuntimeRef = useRef(null); projectSupervisorRuntimeRef.current = projectSupervisorRuntime; + const projectSupervisorResponseStreamRef = + useRef(null); + projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; const projectSupervisorRuntimeSyncingRef = useRef(new Set()); const projectSupervisorRefreshConversationRef = useRef< | (( @@ -15565,14 +15713,29 @@ export function App() { setProjectSupervisorRuntime(nextRuntime); } + function updateProjectSupervisorResponseStream( + incoming: AgentRuntimeResponseStream | null | undefined, + runtime: AgentRuntimeState, + ) { + const nextStream = mergeProjectSupervisorResponseStream( + projectSupervisorResponseStreamRef.current, + incoming, + runtime, + ); + projectSupervisorResponseStreamRef.current = nextStream; + setProjectSupervisorResponseStream(nextStream); + } + function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; + projectSupervisorResponseStreamRef.current = null; projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); + setProjectSupervisorResponseStream(null); setProjectSupervisorRuntimeError(''); } @@ -15702,7 +15865,8 @@ export function App() { (expectedSessionId !== null && nextRuntime.sessionId !== expectedSessionId) || (currentRuntime !== null && - !sameAgentRuntimeRun(nextRuntime, currentRuntime)) + (!sameAgentRuntimeRun(nextRuntime, currentRuntime) || + nextRuntime.updatedAt < currentRuntime.updatedAt)) ) { return; } @@ -15711,6 +15875,10 @@ export function App() { setProjectSupervisorSessionId(nextRuntime.sessionId); } updateProjectSupervisorRuntime(nextRuntime); + updateProjectSupervisorResponseStream( + payload.runtime.responseStream, + nextRuntime, + ); setProjectSupervisorRuntimeError(''); if (invoke) { syncTerminalProjectSupervisorConversation( @@ -15782,6 +15950,9 @@ export function App() { return; } inFlight = true; + const runtimeBeforePoll = projectSupervisorRuntimeRef.current; + const responseStreamBeforePoll = + projectSupervisorResponseStreamRef.current; try { const result = await invoke( 'read_game_creator_agent_runtime', @@ -15791,20 +15962,32 @@ export function App() { sessionId, }, ); - const nextRuntime = agentRuntimeStateFromResult( - result, - projectSupervisorRuntimeRef.current, - ); + const currentRuntime = projectSupervisorRuntimeRef.current; if ( disposed || + currentRuntime !== runtimeBeforePoll || + projectSupervisorResponseStreamRef.current !== + responseStreamBeforePoll || localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId || + projectSupervisorSessionIdRef.current !== sessionId + ) { + return; + } + const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); + if ( nextRuntime.sessionId !== sessionId || - nextRuntime.runId !== trackedRunId + nextRuntime.runId !== trackedRunId || + (currentRuntime !== null && + sameAgentRuntimeRun(nextRuntime, currentRuntime) && + nextRuntime.updatedAt < currentRuntime.updatedAt) ) { return; } updateProjectSupervisorRuntime(nextRuntime); + updateProjectSupervisorResponseStream( + result.responseStream, + nextRuntime, + ); setProjectSupervisorRuntimeError(''); syncTerminalProjectSupervisorConversation( invoke, @@ -16653,6 +16836,19 @@ export function App() { projectConversation.messages, supervisorConversation.messages, ); + const transientResponse = projectSupervisorResponseStreamRef.current; + if ( + transientResponse && + supervisorConversation.messages.some( + (message) => + message.role === 'assistant' && + message.content === transientResponse.accumulatedText && + message.updatedAt >= transientResponse.startedAt, + ) + ) { + projectSupervisorResponseStreamRef.current = null; + setProjectSupervisorResponseStream(null); + } savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = conversationMessages.length; latestMessagesRef.current = conversationMessages; @@ -16712,6 +16908,7 @@ export function App() { ); let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; + let runtimeResponseStream: AgentRuntimeResponseStream | null = null; let runtimeError = ''; if (sessionId) { supervisorConversation = await invoke( @@ -16732,6 +16929,7 @@ export function App() { }, ); runtime = agentRuntimeStateFromResult(runtimeResult); + runtimeResponseStream = runtimeResult.responseStream ?? null; } catch (error) { runtimeError = error instanceof Error ? error.message : String(error); } @@ -16745,6 +16943,12 @@ export function App() { projectSupervisorSessionIdRef.current = sessionId; setProjectSupervisorSessionId(sessionId); updateProjectSupervisorRuntime(runtime); + if (runtime) { + updateProjectSupervisorResponseStream(runtimeResponseStream, runtime); + } else { + projectSupervisorResponseStreamRef.current = null; + setProjectSupervisorResponseStream(null); + } setProjectSupervisorRuntimeError(runtimeError || resumeError); const conversationMessages = mergeProjectSupervisorConversation( projectConversation.messages, @@ -20301,6 +20505,10 @@ export function App() { throw new Error('项目总控 Agent Runtime Session 身份不匹配'); } updateProjectSupervisorRuntime(runtime); + updateProjectSupervisorResponseStream( + runtimeResult.responseStream, + runtime, + ); setProjectSupervisorRuntimeError(''); const refreshConversation = projectSupervisorRefreshConversationRef.current; @@ -20398,6 +20606,7 @@ export function App() { return; } updateProjectSupervisorRuntime(nextRuntime); + updateProjectSupervisorResponseStream(result.responseStream, nextRuntime); setCommandLog((current) => [ ...current, `agent.runtime.${decision} project-supervisor`, @@ -24627,6 +24836,8 @@ export function App() { 0, messages.length - visibleMessages.length, ); + const projectSupervisorTransientReply = + projectSupervisorResponseStream?.accumulatedText.trim() ?? ''; const projectSupervisorStatus = projectSupervisorRuntimeStatusLabel( projectSupervisorRuntime, projectSupervisorRuntimeError, @@ -25261,6 +25472,16 @@ export function App() { ) : null} ))} + {projectSupervisorTransientReply ? ( +

+ {projectSupervisorTransientReply} +

+ ) : null} {projectSupervisorStatus ? (
; +}) { + return { + schemaVersion: 'game-creator-runtime-response-stream.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'supervisor-session-active', + runId, + requestKind: 'final-reply', + requestSlot: `final-reply-loop-${loopIteration}-revision-${responseRevision}`, + appliedSteerCursor, + responseRevision, + sequence, + status, + accumulatedText, + finishReason: status === 'ready' || status === 'committed' ? 'stop' : null, + startedAt: 6000, + updatedAt: 6000 + sequence, + ...overrides, + }; +} + function createProjectSupervisorRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'supervisor-session-active', projectMessages = [], supervisorMessages = [], initialRuntime, + initialResponseStream = null, runtimeMapLoader, }: { projectPath?: string; @@ -114,6 +154,7 @@ function createProjectSupervisorRuntimeHarness({ projectMessages?: Array>; supervisorMessages?: Array>; initialRuntime?: Record; + initialResponseStream?: Record | null; runtimeMapLoader?: () => Promise>>; } = {}) { const manifest = createGameCreationAppManifest( @@ -150,6 +191,8 @@ function createProjectSupervisorRuntimeHarness({ ...overrides, }); let currentRuntime = runtimeState(initialRuntime); + let currentResponseStream = initialResponseStream; + let runtimeReader: (() => Promise>) | null = null; let confirmRuntime: Record | null = null; let rejectRuntime: Record | null = null; let runtimeUpdateHandler: @@ -177,7 +220,10 @@ function createProjectSupervisorRuntimeHarness({ messageId, updatedAt: 2000 + ++messageSequence, }); - const runtimeResult = (state = currentRuntime) => ({ + const runtimeResult = ( + state = currentRuntime, + responseStream = state === currentRuntime ? currentResponseStream : null, + ) => ({ state, sessionPath: `${projectPath}/.agent/runtime/agents/${String(state.agentId)}.json`, eventPath: `${projectPath}/.agent/runtime/events/${String(state.agentId)}.jsonl`, @@ -195,6 +241,7 @@ function createProjectSupervisorRuntimeHarness({ }, recentEvents: [], recentTasks: [], + responseStream, }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -282,6 +329,9 @@ function createProjectSupervisorRuntimeHarness({ ) { throw new Error('unexpected Project Supervisor runtime identity'); } + if (runtimeReader) { + return runtimeReader(); + } return runtimeResult(); } if (command === 'read_game_creator_agent_runtimes') { @@ -305,10 +355,12 @@ function createProjectSupervisorRuntimeHarness({ currentGoal: String(args?.task ?? ''), updatedAt: 3000, }); + currentResponseStream = null; return runtimeResult(); } if (command === 'steer_game_creator_agent_runtime_task') { steerSequence += 1; + currentResponseStream = null; currentSupervisorMessages.push( conversationRecord( 'user', @@ -325,6 +377,7 @@ function createProjectSupervisorRuntimeHarness({ }; } if (command === 'confirm_game_creator_agent_runtime_task') { + currentResponseStream = null; currentRuntime = confirmRuntime ?? runtimeState({ @@ -336,6 +389,7 @@ function createProjectSupervisorRuntimeHarness({ return runtimeResult(); } if (command === 'reject_game_creator_agent_runtime_task') { + currentResponseStream = null; currentRuntime = rejectRuntime ?? runtimeState({ @@ -390,8 +444,18 @@ function createProjectSupervisorRuntimeHarness({ appendSupervisorMessage(message: Record) { currentSupervisorMessages.push(message); }, - emitRuntime(state: Record) { + setResponseStream(responseStream: Record | null) { + currentResponseStream = responseStream; + }, + setRuntimeReader(reader: (() => Promise>) | null) { + runtimeReader = reader; + }, + emitRuntime( + state: Record, + responseStream: Record | null = currentResponseStream, + ) { currentRuntime = state; + currentResponseStream = responseStream; runtimeUpdateHandler?.({ payload: { projectPath, @@ -399,7 +463,7 @@ function createProjectSupervisorRuntimeHarness({ runId: String(state.runId), status: String(state.status), phase: String(state.phase), - runtime: runtimeResult(), + runtime: runtimeResult(currentRuntime, currentResponseStream), }, }); }, @@ -411,7 +475,7 @@ function createProjectSupervisorRuntimeHarness({ runId: String(state.runId ?? ''), status: String(state.status ?? ''), phase: String(state.phase ?? ''), - runtime: runtimeResult(state), + runtime: runtimeResult(state, null), }, }); }, @@ -25178,6 +25242,326 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('recovers a Project Supervisor transient reply from runtime polling without persisting it', async () => { + const runId = 'supervisor-response-stream-recovery-run'; + const initialRuntime = { + runId, + status: 'running', + phase: 'response', + currentTask: '恢复后台总控最终回复', + loopIteration: 1, + appliedSteerCursor: 0, + updatedAt: 6000, + }; + const initialResponseStream = projectSupervisorResponseStream({ + runId, + sequence: 2, + status: 'ready', + accumulatedText: '这是从 Runtime 私有快照恢复的总控回复。', + }); + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime, + initialResponseStream, + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + + expect( + (await screen.findByLabelText('项目总控 Agent 实时回复')).textContent, + ).toBe('这是从 Runtime 私有快照恢复的总控回复。'); + expect(screen.getByText('项目总控 Agent · 回复中')).not.toBeNull(); + expect( + harness.invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + ((args as Record)?.message as { content?: string }) + ?.content === '这是从 Runtime 私有快照恢复的总控回复。', + ), + ).toHaveLength(0); + }); + + it('merges Project Supervisor response deltas monotonically and invalidates them after steer', async () => { + const runId = 'supervisor-response-stream-monotonic-run'; + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: { + runId, + status: 'running', + phase: 'response', + currentTask: '验证总控流式回复', + loopIteration: 1, + appliedSteerCursor: 0, + updatedAt: 6000, + }, + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + const runtime = harness.runtimeState({ + runId, + status: 'running', + phase: 'response', + currentTask: '验证总控流式回复', + loopIteration: 1, + appliedSteerCursor: 0, + updatedAt: 6000, + }); + + await act(async () => { + harness.emitRuntime( + runtime, + projectSupervisorResponseStream({ + runId, + sequence: 1, + accumulatedText: '第一段', + }), + ); + }); + expect( + (await screen.findByLabelText('项目总控 Agent 实时回复')).textContent, + ).toBe('第一段'); + + await act(async () => { + harness.emitRuntime( + { ...runtime, updatedAt: 6002 }, + projectSupervisorResponseStream({ + runId, + sequence: 3, + accumulatedText: '第一段,第二段。', + }), + ); + harness.emitRuntime( + { ...runtime, updatedAt: 6001 }, + projectSupervisorResponseStream({ + runId, + sequence: 2, + accumulatedText: '过期正文不得回退', + }), + ); + harness.emitRuntime( + { ...runtime, updatedAt: 6003 }, + projectSupervisorResponseStream({ + runId, + sequence: 3, + accumulatedText: '同序冲突不得覆盖', + }), + ); + }); + expect(screen.getByLabelText('项目总控 Agent 实时回复').textContent).toBe( + '第一段,第二段。', + ); + + const steeredRuntime = harness.runtimeState({ + ...runtime, + appliedSteerCursor: 1, + queuedSteerCount: 1, + updatedAt: 7000, + }); + await act(async () => { + harness.emitRuntime( + steeredRuntime, + projectSupervisorResponseStream({ + runId, + sequence: 4, + accumulatedText: '纠偏前旧回复', + appliedSteerCursor: 0, + }), + ); + }); + expect(screen.queryByLabelText('项目总控 Agent 实时回复')).toBeNull(); + + await act(async () => { + harness.emitRuntime( + { ...steeredRuntime, queuedSteerCount: 0, updatedAt: 7001 }, + projectSupervisorResponseStream({ + runId, + sequence: 1, + accumulatedText: '纠偏后的新回复。', + appliedSteerCursor: 1, + }), + ); + }); + expect( + (await screen.findByLabelText('项目总控 Agent 实时回复')).textContent, + ).toBe('纠偏后的新回复。'); + expect( + harness.invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + ['第一段', '第一段,第二段。', '纠偏后的新回复。'].includes( + String( + ( + (args as Record)?.message as { + content?: string; + } + )?.content ?? '', + ), + ), + ), + ).toHaveLength(0); + }); + + it('ignores a stale null polling result that returns after a newer Supervisor stream event', async () => { + const runId = 'supervisor-response-stream-poll-race-run'; + const runtime = { + runId, + status: 'running', + phase: 'response', + currentTask: '验证流事件与轮询竞态', + loopIteration: 1, + appliedSteerCursor: 0, + updatedAt: 6002, + }; + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: runtime, + initialResponseStream: projectSupervisorResponseStream({ + runId, + sequence: 1, + accumulatedText: '旧前缀', + }), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + expect( + (await screen.findByLabelText('项目总控 Agent 实时回复')).textContent, + ).toBe('旧前缀'); + + let readStarted = false; + let resolveStaleRead!: (value: Record) => void; + const staleRead = new Promise>((resolve) => { + resolveStaleRead = resolve; + }); + const staleSnapshot = harness.runtimeResult( + harness.runtimeState(runtime), + null, + ); + let readCount = 0; + harness.setRuntimeReader(async () => { + readCount += 1; + if (readCount === 1) { + readStarted = true; + return staleRead; + } + return harness.runtimeResult(); + }); + await waitFor(() => expect(readStarted).toBe(true), { timeout: 2_000 }); + + await act(async () => { + harness.emitRuntime( + harness.runtimeState(runtime), + projectSupervisorResponseStream({ + runId, + sequence: 2, + accumulatedText: '旧前缀,新事件。', + }), + ); + }); + expect(screen.getByLabelText('项目总控 Agent 实时回复').textContent).toBe( + '旧前缀,新事件。', + ); + + await act(async () => { + resolveStaleRead(staleSnapshot); + await staleRead; + }); + expect(screen.getByLabelText('项目总控 Agent 实时回复').textContent).toBe( + '旧前缀,新事件。', + ); + await waitFor(() => expect(readCount).toBeGreaterThan(1), { + timeout: 2_000, + }); + expect(screen.getByLabelText('项目总控 Agent 实时回复').textContent).toBe( + '旧前缀,新事件。', + ); + harness.setRuntimeReader(null); + }); + + it('does not revive a stale nonempty Supervisor stream after a newer same-run event', async () => { + const runId = 'supervisor-response-stream-stale-nonempty-run'; + const runtime = { + runId, + status: 'running', + phase: 'response', + currentTask: '验证旧非空流不会复活', + loopIteration: 1, + appliedSteerCursor: 0, + queuedSteerCount: 0, + updatedAt: 6002, + }; + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: runtime, + initialResponseStream: projectSupervisorResponseStream({ + runId, + sequence: 1, + accumulatedText: '纠偏前回复', + }), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + expect( + (await screen.findByLabelText('项目总控 Agent 实时回复')).textContent, + ).toBe('纠偏前回复'); + + let readCount = 0; + let resolveStaleRead!: (value: Record) => void; + const staleRead = new Promise>((resolve) => { + resolveStaleRead = resolve; + }); + const staleSnapshot = harness.runtimeResult( + harness.runtimeState(runtime), + projectSupervisorResponseStream({ + runId, + sequence: 2, + accumulatedText: '这条旧轮询正文不得复活', + }), + ); + harness.setRuntimeReader(async () => { + readCount += 1; + if (readCount === 1) { + return staleRead; + } + return harness.runtimeResult(); + }); + await waitFor(() => expect(readCount).toBe(1), { timeout: 2_000 }); + + await act(async () => { + harness.emitRuntime( + harness.runtimeState({ + ...runtime, + appliedSteerCursor: 1, + updatedAt: 6002, + }), + null, + ); + }); + expect(screen.queryByLabelText('项目总控 Agent 实时回复')).toBeNull(); + + await act(async () => { + resolveStaleRead(staleSnapshot); + await staleRead; + }); + expect(screen.queryByLabelText('项目总控 Agent 实时回复')).toBeNull(); + await waitFor(() => expect(readCount).toBeGreaterThan(1), { + timeout: 2_000, + }); + expect(screen.queryByLabelText('项目总控 Agent 实时回复')).toBeNull(); + harness.setRuntimeReader(null); + }); + it('refreshes one Project Supervisor assistant after the terminal event', async () => { const harness = createProjectSupervisorRuntimeHarness(); window.__TAURI__ = { @@ -25204,6 +25588,28 @@ describe('AI 游戏创作 App 界面边界', () => { ([command]) => command === 'start_game_creator_agent_runtime_task', ); const runId = String(startCall?.[1]?.runId ?? ''); + const respondingRuntime = harness.runtimeState({ + runId, + status: 'running', + phase: 'response', + loopIteration: 1, + appliedSteerCursor: 0, + updatedAt: 8000, + }); + await act(async () => { + harness.emitRuntime( + respondingRuntime, + projectSupervisorResponseStream({ + runId, + sequence: 2, + status: 'ready', + accumulatedText: '唯一的总控回复', + }), + ); + }); + expect( + (await screen.findByLabelText('项目总控 Agent 实时回复')).textContent, + ).toBe('唯一的总控回复'); const assistant = { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', @@ -25216,19 +25622,26 @@ describe('AI 游戏创作 App 界面边界', () => { harness.appendSupervisorMessage({ ...assistant }); const completedRuntime = harness.runtimeState({ runId, - status: 'completed', + status: 'idle', phase: 'completed', lastResponse: '唯一的总控回复', updatedAt: 9000, }); await act(async () => { - harness.emitRuntime(completedRuntime); - harness.emitRuntime(completedRuntime); + const committedStream = projectSupervisorResponseStream({ + runId, + sequence: 3, + status: 'committed', + accumulatedText: '唯一的总控回复', + }); + harness.emitRuntime(completedRuntime, committedStream); + harness.emitRuntime(completedRuntime, committedStream); }); expect(await screen.findByText('唯一的总控回复')).not.toBeNull(); expect(screen.getAllByText('唯一的总控回复')).toHaveLength(1); + expect(screen.queryByLabelText('项目总控 Agent 实时回复')).toBeNull(); expect( harness.invoke.mock.calls.filter( ([command, args]) => diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 621fd5cad..13102ca74 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4599,6 +4599,15 @@ - 边界:短入口只复用现有 Swarm CLI、External Runner、Supervisor active Session、conversation、黑板、记忆和 durable 委派协议,不新增 Agent、HTTP 服务、数据库或旁路 Provider 调用。 - 验收:CLI 单测覆盖省略 ID 默认总控和显式 ID 兼容;真实入口 smoke 用一次性项目启动 `agc:chat`,终端显示 `project-supervisor`、创建空总控 Session,并在未发起 LLM 请求时通过 `/quit` 正常退出和清理。 +## 2026-07-15 后台 Agent 最终回复使用真实增量流 + +- 决策:对标 Codex streamed agent events 时,现有 Runtime state/event 继续承担工具和阶段进度,只有 `phase=response` 的最终用户可见回复输出 Provider SSE delta;planning、function arguments、thinking 和 observation 不进入流,也不允许客户端拆字伪装。 +- 持久边界:`.agent/runtime/response-streams//.json` 是绑定 Agent/task/Session/run/request slot/steer cursor/revision 的私有、可丢失展示缓存。路径 hash 取稳定身份 SHA-256 十六进制前 32 位;conversation assistant、Provider lifecycle、finalization journal 和 Runtime task/state 仍是完成事实源,公共审计只存流状态、sequence、字符数和哈希。 +- 控制边界:流式配置只改变同一 lifecycle 唯一物理请求的传输方式,不增加 fallback 重放。steer、Goal 控制、取消、失败、revision 漂移和 reconciliation 会让旧流失效;最终候选仍经过原 verification/plan/Goal/finalization 门禁并恰好一次写入 assistant。 +- 客户端:普通 Project Supervisor 用 runtimeOwned 临时 assistant 渲染匹配流,刷新从 Runtime 轮询恢复;CLI 按 accumulated text 增量输出并避免 settle 后重复整段。真实验收必须证明至少两个公开 delta 先于终态、最终全文一致、单物理请求和公共面零正文泄漏。 +- 审计收口:`project.verify` 执行后只允许把精确的 `.agent/logs/command.log` 相对路径写入 Agent DB;expectedCommand 和 output 在公共审计落盘前必须替换项目根路径,其中 output 保留有界尾部供诊断。路径不在该精确位置时,执行结果进入 reconciliation,不能把宿主绝对路径写入公共面。 +- 验收:2026-07-15 真实 `gpt-5.5` `response-stream` suite PASS。39 个不同非空快照先于终态,sequence `1 -> 418 -> 425 committed`,最终 883 字;唯一 assistant、唯一 final-reply `started -> completed` lifecycle、4 段 finalization,fallback replay、重复 message/receipt 均为 0。上游物理请求数未直接观测,报告明确使用 lifecycle slot 与 canonical response identity 证明模式。公共正文、API Key、thinking、诱饵、项目路径和 transcript/report 路径泄漏均为 0,隔离 Runner/AppData/项目完成精确清理。 + ## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环 - 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index e4697c4cd..33387871b 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -802,11 +802,41 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任 - 最终 Goal、Runtime 和最新 task 必须在原 Agent/Session/run 上 completed,结构化计划全部完成,Goal completion evidence 必须精确匹配当前 Goal/plan/verification/run/session;同一 finalizationId 必须按严格七槽物理顺序形成完整记录,finalization sidecar 最终不残留,目标 Session 只允许一个 assistant。Goal sidecar、task JSONL、Runtime state、v4 context 和 conversation 属于本地私有执行事实,可包含完成目标所需正文;event、Agent DB、receipt、activity、output 与最终报告不得保存 task、Goal/steer、委派任务、verify 命令或 error 正文,只允许身份/状态、SHA-256、字符/字节/条目计数和经 URL、项目根、其它绝对路径及凭据清洗的有界摘要。公共 task 统一不保留正文;委派只保留 `taskSha256 / taskChars`,verify 只保留脚本安全标识、`expectedCommandSha256 / expectedCommandChars`、timeout 和结果计数,error 只保留 kind/fingerprint/chars 或脱敏摘要,禁止任何正文、preview、head 或 tail。验收必须扫描完整 Goal/编辑/委派/verify/error canary、已加载密钥和一次性项目绝对路径在全部公共持久面泄漏为 0,并确认动作、消息、receipt 和 Provider lifecycle 均无重复。 - 截至 2026-07-15,V1.18 真实 Provider 门禁尚未通过。最新保留现场在首轮 planning、`planRevision=0`、零 pending action/observation 时由对端关闭长 TLS 连接;同一发布配置、模型和 Rust native-tls 客户端的最小单 Agent 请求在 25.2 秒成功,证明基础鉴权与短请求通道可用,但不能外推为工具 planning 或 Goal 长链路 PASS。Provider 长请求恢复后仍需完整执行上一条一次性项目验收。 +## V1.19 后台 Agent 真流式最终回复 + +V1.19 对标 Codex 富客户端的增量 turn 事件:工具开始、完成和等待继续沿用现有 Runtime state/event;只有已经进入 `phase=response` 的用户可见最终回复允许输出 Provider 文本 delta。后台工具 planning、function arguments、`thinkingSummary`、原始 observation 和修复上下文不得进入流。禁止前端拆字、定时补字或先生成完整正文再伪装流式。 + +### 流身份与私有快照 + +- 新增 `game-creator-runtime-response-stream.v1` 私有快照,路径固定为 `.agent/runtime/response-streams//.json`,两个 hash 都取稳定身份 SHA-256 十六进制前 32 位。记录绑定 `agentId / taskId / sessionId / runId / requestKind=final-reply / requestSlot / appliedSteerCursor / responseRevision`,并保存单调 `sequence`、`status=streaming|ready|committed|discarded|failed`、`accumulatedText`、可选 `finishReason` 和时间。正文最多 32000 字符;路径、标识、schema、状态、sequence 和正文限制任一不合法时只关闭该展示流,不能把不可信内容显示给用户或据此恢复 Runtime。 +- 流快照是可丢失的本地展示缓存,不是 assistant、Provider lifecycle、任务完成或 finalization 的事实源。写入采用同路径原子替换并允许节流;Tauri 关闭、CLI 断线或单次快照写失败不能让已经可靠完成的 Provider 请求失败。`AgentRuntimeResult.responseStream` 只在快照与当前 Runtime 的 Agent/Session/run、`phase=response|finalizing|completed`、steer cursor 和请求身份一致时返回。 +- 开始新的 final-reply request slot 时先写空 `streaming` 快照。SSE delta 只在经过增量 `...` 过滤后追加;标记可跨 chunk,未闭合 thinking 永不外显。sequence 只随公开 accumulated text 或 finish reason 的真实变化增加。Provider 完整返回后用最终 `strip_llm_thinking_blocks` 结果校准为 `ready`,确保草稿与最终候选一致。 + +### Provider、控制与 finalization 边界 + +- 后台 Agent 继续严格服从 `agentLlm..stream`:为 `true` 时即使 planning 已带候选 `response`,也必须进入独立 final-reply lifecycle,并由该 lifecycle 的唯一物理请求使用 `LlmClient::stream_run`;planning 候选只在这次请求失败时作为 fallback。为 `false` 时可直接采用完整 planning response;只有 planning 未带 response 而确需独立 final-reply lifecycle 时才使用一次 `run`,并在完整结果后写一次 `ready`。流式协议失败不得在同一 Provider lifecycle 内静默补发普通请求;V1.18 的单物理请求、request slot、orphan barrier、pause/steer interrupt 和显式恢复新 lifecycle 约束保持不变。 +- same-run steer、Goal edit/pause、取消、stale revision、Provider 失败或 reconciliation 都必须让旧快照进入 `discarded|failed`,或因 steer cursor/phase 不匹配而立即不可见。旧草稿不能成为 observation、fallback response、conversation message、Goal completion evidence 或下一轮模型上下文。 +- 完整候选仍必须通过 verification、plan、Goal、process/join/delegate 和项目 revision 门禁。`finish_game_creator_agent_background_runtime_turn_at` 仍是唯一 finalization 入口;只有 assistant 已按稳定 messageId 恰好一次写入并完成 Runtime 投影后,快照才可标记 `committed`。失败消息和 `plan.response` fallback 必须覆盖为其实际候选,不能保留不同 Provider 草稿。 +- 公共 event、Agent DB、receipt、activity/output 和报告不得复制 delta 或 accumulated text,只记录流身份、状态、sequence、字符数和 SHA-256。conversation、finalization、Runtime task/state 和 response-stream 都是本地私有事实面,可保存 canonical 最终正文;其中 response-stream 只是可丢失候选缓存,且不得保存 API Key、请求头、URL、模型 thinking、工具计划或原始 Provider error。 + +### 客户端与 CLI + +- 普通 Project Supervisor 聊天把匹配的 `streaming|ready` 快照渲染成一条 `runtimeOwned` 临时 assistant 消息;刷新、窗口重开和 Tauri event 丢失时由现有 750ms Runtime 轮询恢复。`committed` 后以 conversation 中的规范 assistant 替换草稿,不把临时消息写回 legacy project conversation 或 Agent Session。 +- `agc:chat` / `agc:swarm` 按 accumulated text 前缀增量打印 UTF-8 suffix;新 request slot、非前缀校准或 reconnect 要明确重置。已经完整流出的父回复在 settle 时只补完成换行/状态,不再整段重复打印。`/status` 只显示流状态、sequence 和字符数,不显示隐藏 planning 或 thinking。 + +### 验收口径 + +- 确定性测试覆盖 Chat / Responses SSE 至少两个真实 delta、chunk 边界 thinking 过滤、sequence 单调、32K 上限、损坏/错身份快照不显示、Tauri 轮询恢复、CLI suffix/reconnect/非前缀重置、steer/取消/失败旧流失效、非流配置单次 ready、finalization 后唯一 assistant 与 committed 精确一致,以及公共持久面零正文。 +- 真实 Provider 使用一次性项目和独立 AppData,把目标 Agent 的 `stream=true`,证明首次公开 delta 发生在 Provider/finalization 终态之前、至少两个非空增量可观察、最终 conversation assistant 与 ready/committed 全文一致、同一 lifecycle 不发生应用层重试,并扫描密钥、thinking canary、项目绝对路径和 delta 正文在 event、Agent DB、receipt、activity/output 与报告等公共面泄漏为 0。上游物理请求数无法直接观测时,必须明确记录证明模式,不能把 lifecycle 计数冒充网络请求计数。 +- 2026-07-15 真实 `gpt-5.5` `response-stream` suite 已 PASS:隔离 AppData 只以 hardlink 读取正式配置并使用无密钥 `stream=true` overlay,正式配置 CLI 调用为 0、源 Runner endpoint 未变化。39 个不同非空 streaming 快照先于终态,sequence 从 1 单调推进到 418,最终以 425 committed;canonical 正文 883 字,conversation 恰好 1 条 user 和 1 条 assistant,final-reply lifecycle 恰好 1 组 `started -> completed`,fallback replay、重复 message/receipt 均为 0。该次上游物理请求计数未直接观测,证明模式为 lifecycle slot 与 canonical response identity 交叉核对。公共正文、API Key、thinking、诱饵、项目绝对路径及 transcript/report 路径泄漏均为 0;隔离 Runner 由 Linux pidfd 精确停止,AppData 和一次性项目按 sentinel 清理。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent_goal_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml goal_context_bundle_v4_migrates_v3_and_v2_then_rejects_plan_mismatch -- --nocapture` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml response_stream_ -- --nocapture` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml swarm_cli::tests -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml typed_goal_pause_and_cancel_require_durable_intent_and_keep_exact_run -- --nocapture` - `npm run ai-game-creator-shell:typecheck` - `npm run test -- apps/ai-game-creator-shell/tests` @@ -816,6 +846,7 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任 - `npm run ai-game-creator-shell:agent-run:smoke` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite llm-runtime` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite goal-runtime` +- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite response-stream` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite full` - `npm run check:encoding` - `git diff --check` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 673cafdb4..4c7736578 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -557,4 +557,6 @@ game-project/ - 共享契约提供 `GAME_CREATION_APP_LIMITED_RUN_COMMANDS`;当前真实命令为 `game.static_smoke`,用于检查 `game/index.html` 的可玩原型门槛并写入 `.agent/logs/command.log`。 - 后台 Agent 的项目 revision 以 `.agent/runtime/project-revision.json` 为唯一事实源,per-run 验证门禁以 `.agent/runtime/verification//.json` 为事实源。每次 `file.write`、`file.patch`、`file.delete` 或 `project.restore` 都必须在实际修改前保守推进 revision,并永久记住当前 run 的 `requiresVerification=true`;失败或崩溃不回退。只有成功且绑定当前 revision 的 `project.verify` 或 `command.run_limited / game.static_smoke` 才能放行空 actions;未修改项目的只读任务不强制验证,但最终回复仍必须绑定请求开始时的 `responseRevision`。per-run context bundle 使用 v2,pending action 使用 v3 并绑定创建时的全局 revision;旧版恢复失败关闭。最终 assistant 和 completed 必须在项目写锁内重读 revision / gate 后依次落盘,文件回读、observation 或锁外旧快照都不能替代验证凭证。验收必须分别模拟待执行动作、修改 run 与只读 run 的跨 Agent revision 漂移,证明旧动作不执行、旧回复不落盘、不产生 completed 或 failed、per-Agent 锁不提前释放、原 run/session 在收到 blocker 后保持可恢复;stale continuation 经重启仍从原 `nextLoopIndex` 续跑,revision 数值或成功验证输出中的动态时间戳不能绕过 context stall。 - `.agent/manifest.json` 会记录当前 `preview` 状态和 `commandRuns` 受限命令运行结果,作为本地产物索引的最小真相源。 +- 2026-07-15 补充:后台 Runtime 的最终用户回复接入真 Provider SSE。planning/function arguments/thinking/observation 继续只留在私有执行链;`AgentRuntimeResult` 读取与 CLI 通过 `.agent/runtime/response-streams//.json` 的有界私有快照恢复公开 accumulated text。快照绑定 Agent/task/Session/run/request slot/steer cursor/revision,只是可丢失展示缓存,不替代 conversation、Provider lifecycle 或 finalization。普通 Project Supervisor 以 runtimeOwned 草稿展示,最终仍由唯一 assistant 落盘替换;steer、取消、失败和身份漂移必须隐藏旧草稿,公共审计只保留哈希与计数。 +- 2026-07-15 真实 `gpt-5.5` `response-stream` 专项已 PASS:39 个不同非空快照在终态前可见,sequence 为 `1 -> 418 -> 425 committed`,最终 883 字与唯一 conversation assistant 精确一致;final-reply lifecycle 唯一、fallback replay 和重复消息/回执为 0。公共正文、API Key、thinking、诱饵和项目绝对路径泄漏均为 0;`project.verify` Agent DB 审计固定保存 `.agent/logs/command.log` 相对路径,并在写入前脱敏 expectedCommand/output 中的项目根路径。 - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 1adec6c84..8e2bfe223 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -9,7 +9,7 @@ use std::{ }; use log::{debug, warn}; -use reqwest::{Client, StatusCode}; +use reqwest::{Client, StatusCode, redirect::Policy}; use serde::{Deserialize, Serialize}; use tokio::time::sleep; @@ -1030,6 +1030,21 @@ impl LlmClient { }) } + pub fn new_without_redirects(config: LlmConfig) -> Result { + let http_client = Client::builder() + .http1_only() + .redirect(Policy::none()) + .build() + .map_err(|error| { + LlmError::InvalidConfig(format!("构建 reqwest client 失败:{error}")) + })?; + + Ok(Self { + config, + http_client, + }) + } + pub fn config(&self) -> &LlmConfig { &self.config } @@ -2655,6 +2670,61 @@ mod tests { assert_eq!(request_json["official_fallback"], serde_json::json!(true)); } + #[tokio::test] + async fn client_without_redirects_does_not_replay_post_on_307() { + let redirect_listener = TcpListener::bind("127.0.0.1:0").expect("redirect listener"); + let redirect_address = redirect_listener.local_addr().expect("redirect address"); + let target_listener = TcpListener::bind("127.0.0.1:0").expect("target listener"); + let target_address = target_listener.local_addr().expect("target address"); + target_listener + .set_nonblocking(true) + .expect("target listener nonblocking"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = redirect_listener.accept().expect("redirect request"); + let request = read_request(&mut stream); + write!( + stream, + "HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{target_address}/responses\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .expect("write redirect response"); + request + }); + + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + format!("http://{redirect_address}"), + "test-key".to_string(), + "gpt-5".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + 0, + 1, + ) + .expect("redirect test config"); + let client = + LlmClient::new_without_redirects(config).expect("redirect-disabled client builds"); + let error = client + .run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses()) + .await + .expect_err("307 must remain an upstream response"); + + assert!(matches!( + error, + LlmError::Upstream { + status_code: 307, + .. + } + )); + let source_request = server_handle.join().expect("redirect server joins"); + assert_eq!( + source_request.matches("POST /responses HTTP/1.1").count(), + 1 + ); + assert!(matches!( + target_listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + #[test] fn sse_parser_handles_split_chunks_and_done_marker() { let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat);