diff --git a/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md b/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md index 717a4675..362601a3 100644 --- a/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md +++ b/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md @@ -8,11 +8,12 @@ - Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260923-yuxi-teachers-client-53ba33e2 - Base commit: 1f2ad3fb3a71b8b35fabb5b319cd3b54b4c2ebbe - Owner: codex -- Status: Planning +- Status: Ready for Integration ## Scope - Implement the accepted Yuxi coding-teacher design across Yuxi runtime, Works Square distribution and MakeLore Main/local context. This record owns the ml repository changes only. User explicitly confirmed student-account billing on 2026-09-23. +- User-authorized follow-up on 2026-09-23 fixes the confirmed client SSE routing omission: child-agent text must not become the main teacher answer. This follow-up changes only MakeLore Main, its regression test and documentation. ## Intent And Constraints @@ -26,19 +27,19 @@ - Implemented teacher selection for new topics, Main-owned scoped Yuxi sessions/SSE, local project/Pi snapshot binding, complete structured read results and native cloud continuation. Reconnect follows persisted continuation and cursor; original question ID supports cancellation even if submission acknowledgment is lost. - Main enforces existing project read boundaries; frozen public Pi messages and current teacher topic texts can be read by ID. Legacy teacher histories retain their original path. Disabled teacher topics remain readable without borrowing another teacher's availability. Cloud topic headers reflect the selected teacher, including implicit topic creation on first send. - Updated README with runtime, configuration/version, local read, cancellation and billing behavior. Implementation and local evidence complete. The user-authorized fresh read-only reviewer independently reviewed all three repositories and returned PASS with no reproducible defects. Prepared for integration; no merge, push, deployment, installer release or paid-model calls. -- Follow-up review on 2026-09-23 reopened integration readiness: cloud-runner appends every message_delta without routing by the envelope's cloud thread_id. A supported Yuxi teacher using subagents can therefore mix child-agent text into the teacher response, saved history and brought-back draft. The previous review/test set did not exercise this case. No implementation changes were made during this review. +- Resolved the follow-up review finding on 2026-09-23: cloud-runner now routes message text using the authoritative cloud main thread_id from the Run view, which differs from the local topic ID. Child-thread events no longer enter the teacher response, saved history or brought-back draft, or reset main-answer accumulation. Every observed event still advances the reconnect cursor. This bounded client fix received direct diff review; the earlier independent reviewer PASS applies to the initial implementation only. ## Verification -- Typecheck passed. All 50 relevant tests across coding-teacher, model, read-tools, UI and cloud runner passed. Cloud runner tests use real temporary project files and captured public messages, asserting all result IDs/content, stale-context rejection, saved continuation, lost acknowledgment cancellation and SSE replay/partial final-answer repair. Service integration also validates native Yuxi topics with no local prompt use cloud credentials, local reads and saved cloud request/response rather than the local model loop. +- Typecheck passed. All 51 relevant tests across coding-teacher, model, read-tools, UI and cloud runner passed after the routing fix (50 at initial implementation). Cloud runner tests use real temporary project files and captured public messages, asserting all result IDs/content, stale-context rejection, saved continuation, lost acknowledgment cancellation and SSE replay/partial final-answer repair. Service integration also validates native Yuxi topics with no local prompt use cloud credentials, local reads and saved cloud request/response rather than the local model loop. - Electron E2E project teacher side chat passed against actual built window with explicit Host API fixtures, including choosing a non-default teacher and bringing its response back to the main draft without submitting. Production Renderer/Main/Preload build passed. Focused ESLint and git diff whitespace checks passed. - Yuxi real HTTP/PG/worker integration separately verifies cloud interruption/continuation; WS verifies student wallet. No real paid provider or deployed three-service topology call. -- A task-local regression probe under ignored node_modules/.cache/teacher-review supplies the real SSE envelope shape emitted by Yuxi ChunkedEventWriter: a child-thread message followed by a parent-thread reply. Command: pnpm exec vitest run --config node_modules/.cache/teacher-review/vitest.config.mjs -t 'keeps child-agent text'. Result: 1 failed / 5 not selected. Expected teacher-only text; actual output contains child text followed by teacher text. This is a confirmed routing defect, not a provider or timing failure. +- Added a tracked regression with interleaved main/child text, both payload.items and payload.chunk, cloud/local thread-ID distinction and disconnection after a child event. It failed before the fix with child text plus a duplicated final answer, then passed with teacher-only text and after_seq=2-0 on reconnect. Cursor assertions run outside the transport callback so transport retry handling cannot swallow assertion failures. All six cloud tests passed again after that assertion refinement. +- The original task-local probe under ignored node_modules/.cache/teacher-review now passes: pnpm exec vitest run --config node_modules/.cache/teacher-review/vitest.config.mjs -t 'keeps child-agent text' (1 passed / 5 not selected; previously 1 failed). Focused ESLint, typecheck and production Renderer/Main/Preload build passed for this follow-up. Existing Electron E2E was not rerun: its Host API topic fixture bypasses the changed cloud transport; the tracked transport regression exercises the actual routing defect. ## Follow-ups - Integrate the three paired task branches together when requested. Deployment, real provider billing, remote MCP and production three-service/one-api topology validation remain a separate release step. Retain task worktrees and branches for that handoff. -- Before integration, use the authoritative cloud thread ID to keep child stream messages out of the main teacher response, and add the reproduced case to the tracked cloud-runner regression suite. Keep cursor advancement for all observed stream events so reconnection does not replay ignored child events. ## Promotion Candidates diff --git a/README.md b/README.md index fa396837..80b16f66 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Main 为每次提问绑定账号、项目和 Pi 当前已完成分支,提供 `list_project_files`、`read_project_file`、`read_conversation` 三个只读工具。Yuxi 通过持久中断下发整批调用,Main 返回配对结果后继续同一问题。文件按读取时的内容提供;会话原文来自本轮捕获的公开消息,不上传 Pi 原始日志或思考。可读取 `.makelore/project.json`,其余 `.makelore` 记录与 `.git` 内部数据不可读,也不提供本地写入或命令执行。 - 文件预览上限 256 KiB;工具默认每次 60 行,最多 100 行、2400 字节,并说明节选或错误。每个问题最多六批本地读取,续接保留原截止时间和学生付款人。长消息可按消息 ID 读取本轮原文;云端保存老师对话及收到的片段,完整本地工程仍保留在客户端。 - 话题按账号、项目和来源会话保存在本机,云端使用学生隔离的对应线程。关闭面板或切换会话不取消已接受的问题;停止、退出账号会停止本地读取并请求取消云端原问题。断线按事件游标和持久状态恢复,重复提交沿用原问题和工具批次;重启后未完成问题标记中断,下次提问先停止旧问题并重新绑定本地上下文。停用老师后历史仍可查看。旧版本地老师话题保留原执行与历史读取路径。 -- 回答可复制或追加到主输入框草稿,由用户决定发送;归档来源需先恢复。删除来源会话会停止并删除本机关联老师记录。运营菜单只提供老师列表、选择下发、同步、默认和启停操作,提示词编辑在 Yuxi 完成。 +- 老师回答只展示并保存云端主线程的文本,子智能体输出不混入正文。回答可复制或追加到主输入框草稿,由用户决定发送;归档来源需先恢复。删除来源会话会停止并删除本机关联老师记录。运营菜单只提供老师列表、选择下发、同步、默认和启停操作,提示词编辑在 Yuxi 完成。 ## 运行内核与项目数据 diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index aaf52ef6..f818e16c 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -308,6 +308,7 @@ export function prepareCloudTeacher( typeof detail === 'string' ? detail : '老师回复已停止。' ); } + const threadId = identifier(view.thread_id); onProgress('老师正在思考…'); try { await transport.events( @@ -318,6 +319,8 @@ export function prepareCloudTeacher( bounded, (_event, envelope, id) => { if (id) cursor = id; + // Yuxi 的父 Run 也包含子线程事件,老师正文只接收云端主线程文本。 + if (envelope.thread_id !== threadId) return; const payload = envelope.payload ? object(envelope.payload) : {}; for (const item of Array.isArray(payload.items) ? payload.items diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts index e77b82a9..bd79fa9d 100644 --- a/tests/unit/coding-teacher-cloud.test.ts +++ b/tests/unit/coding-teacher-cloud.test.ts @@ -257,10 +257,13 @@ it('reconnects from the last cursor and fills the final message after an earlier const transport: TeacherCloudTransport = { json: vi.fn(async (url) => { if (url === '/questions') return { request_id: 'question', run_id: 'one' }; - return ++reads < 3 ? { status: 'running' } : { status: 'completed', output: '最终建议' }; + return ++reads < 3 + ? { status: 'running', thread_id: 'teacher-thread' } + : { status: 'completed', output: '最终建议' }; }), events: vi.fn(async (url, _signal, accept) => { const message = (id: string, content: string) => ({ + thread_id: 'teacher-thread', payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] }, }); if (++streams === 1) { @@ -285,3 +288,62 @@ it('reconnects from the last cursor and fills the final message after an earlier expect(text.join('')).toBe('我先检查。\n\n最终建议'); expect(f.progress).toHaveBeenCalledWith('连接中断,正在恢复老师回复…'); }); + +it('keeps only the cloud main-thread answer while advancing past child events on reconnect', async () => { + const f = await fixture(); + let reads = 0; + let streams = 0; + const text: string[] = []; + const streamUrls: string[] = []; + const transport: TeacherCloudTransport = { + json: vi.fn(async (url) => { + if (url === '/questions') return { request_id: 'question', run_id: 'one' }; + return ++reads < 3 + ? { status: 'running', thread_id: 'teacher-thread' } + : { status: 'completed', output: '老师最终答复' }; + }), + events: vi.fn(async (url, _signal, accept) => { + streamUrls.push(url); + const message = (threadId: string, id: string, content: string) => ({ + thread_id: threadId, + payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] }, + }); + if (++streams === 1) { + accept('message', message('teacher-thread', 'answer', '老师'), '1-0'); + accept('message', message('child-thread', 'child-answer', '子智能体内容'), '2-0'); + throw new Error('connection interrupted'); + } + accept( + 'message', + { + thread_id: 'child-thread', + payload: { + chunk: { + stream_event: { + type: 'message_delta', + message_id: 'child-answer', + content: '子智能体后续内容', + }, + }, + }, + }, + '3-0' + ); + accept('message', message('teacher-thread', 'answer', '最终答复'), '4-0'); + }), + }; + await prepareCloudTeacher( + f.account, + f.topic, + requestId, + f.access, + f.progress, + f.saveRequest, + transport + ).run([{ role: 'user', content: '检查' }], new AbortController().signal, (delta) => + text.push(delta) + ); + expect(text.join('')).toBe('老师最终答复'); + expect(transport.events).toHaveBeenCalledTimes(2); + expect(streamUrls[1]).toContain('after_seq=2-0'); +});