(
@@ -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);