From 4b41c23a3a5e9da1a329b1b69930787a670163de Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 28 Sep 2026 16:21:14 +0800 Subject: [PATCH] fix: isolate delivered agent chats by project --- .../20260928-agent-project-chat-6644c06d.md | 54 ++++++ README.md | 2 +- electron/api/routes/coding-teacher.ts | 28 +-- electron/coding-teacher/conversation-store.ts | 71 ++++++- electron/coding-teacher/service.ts | 46 ++--- shared/coding-teacher.ts | 2 +- src/lib/coding-teacher.ts | 15 +- src/pages/Chat/CodingChatPanel.tsx | 2 +- src/pages/Chat/TeacherChatPanel.tsx | 46 +++-- src/pages/Chat/use-teacher-companion.ts | 32 ++-- tests/e2e/pi-coding-first-chat.spec.ts | 88 +++++++-- tests/unit/coding-teacher-ui.test.tsx | 5 +- tests/unit/teacher-companion.test.tsx | 53 +++--- .../unit/teacher-conversation-panel.test.tsx | 61 ++++-- tests/unit/teacher-conversation.test.ts | 178 ++++++++++++++---- 15 files changed, 518 insertions(+), 165 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260928-agent-project-chat-6644c06d.md diff --git a/.project-docs/30-worklog/tasks/20260928-agent-project-chat-6644c06d.md b/.project-docs/30-worklog/tasks/20260928-agent-project-chat-6644c06d.md new file mode 100644 index 00000000..13dac3ba --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-agent-project-chat-6644c06d.md @@ -0,0 +1,54 @@ +# Task: Isolate delivered agent conversations by project + +## Identity + +- Task ID: 20260928-agent-project-chat-6644c06d +- Mode: Feature +- Branch: codex/20260928-agent-project-chat-6644c06d-agent-project-chat +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260928-agent-project-chat-6644c06d +- Base commit: a21a1f077cc11976213290a6b843bb74be97ab44 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Correct visible ongoing conversation identity to account + project + delivered agent. Own Main store/API, per-project history/drafts/read receipts and related UI/tests. + +## Intent And Constraints + +- User explicitly clarified that different projects must have different conversations with the same agent; this supersedes the prior cross-project assumption. Keep one ongoing chat inside each project, with no new-topic/dropdown controls. +- Concurrent and Planning Gates Passed: official start/status match Identity; required context reused from preceding integration plus latest main refresh/UI amendments. Read 132 peer task records, retaining historical placeholder scopes as unknown. Observer is separately ready and not merged; authorized coordination sent with corrected identity. +- Preserve current Main authentication, student payer, frozen Pi/version/read scope, configuration refresh and UI polish. Retain original legacy/mixed history; import only turns proven to belong to the selected project. No new subagents, paid-model calls, merge, push, deployment or cleanup. +- Plan: reverse the existing two-project service/UI expectations to reproduce the reported behavior; bind project at storage and Host API boundaries; split existing global history by per-turn project without deleting originals; scope drafts/read receipts/cache/subscriptions; run relevant unit/type/lint/build/Electron regressions. +- diagnosing-bugs broad hypothesis enumeration is unnecessary here: the previous implementation and its explicit cross-project test encode the now-corrected requirement. Use the failing two-project regression as the feedback loop. + +## Outcome + +- Completed project isolation: the visible chat, store/cache ownership, acceptance locks, SSE, cancel/discussion/save/seen routes and Renderer state use account + project + delivered agent. Same-project Pi or published-version changes retain the visible chat while preserving the existing per-turn execution scope. +- Host API is now /api/coding/projects/:projectId/agent-conversations/:agentId with the same child actions. service.conversation(projectId, agentId, before?) and markConversationSeen(projectId, agentId, requestId) bind reads/receipts; sendConversation(agentId, input) requires input.projectId. New storage sits under userData/agent-conversations/account/agent/projects/projectId. +- Upgrade reads the previous global manifest and turn files without modifying them. Proven per-turn project ownership partitions history, preserves original/cloud request IDs, maps each project's seen position, copies its discussion and forces a new cloud checkpoint. Interrupted index writes resume idempotently; unowned messages remain in the original archive. Legacy project topic imports retain origin deduplication. +- Per-project drafts, cached pages/read anchors, selected contact and unread receipts restore on switching back. An old global draft is adopted only by its recorded project, ahead of older pre-single-chat project drafts; all originals remain intact. Delayed reads/SSE/external topics cannot populate another project's chat. +- Current main catalog refresh/hover introduction behavior retained; no new-topic/dropdown controls reintroduced. README updated. No paid cloud call, production deployment, main merge or task directory cleanup. + +## Verification + +- Red reproduction: the new two-project service test failed because both projects returned the same chat ID before implementation. +- 382 relevant tests passed across 12 files: persistence/real HTTP routes/pagination/migration/partial-write retry/cloud segment scope/concurrent project runs/account isolation, panel/project draft restoration/late responses, catalog refresh, guidance/discussions/read tools/model/cloud/activity contracts. The final service file passed 11/11; final Renderer files passed 77/77; unchanged supporting files passed 294/294. +- pnpm 10.33.4 run typecheck passed. Changed-file ESLint and git diff --check passed. build:vite passed (Renderer/Main/Preload/utility); existing Browserslist/chunk/dynamic-import warnings remain. +- Electron: three specs passed (project consultations, new two-project chat/draft/reload regression, manual catalog refresh). The two-project spec was rerun and passed after the final draft migration adjustment. Test uses isolated app data and mocked model responses; screenshot inspected, not a production cloud/model acceptance. +- Expanded Main tsc reports 66 diagnostics outside the changed teacher/store/API modules; this matches the previously documented count but is not a clean global Main typecheck. Current diagnostics are in local temp agent-project-chat-main-tsc.txt. No unrelated fixes were made. +- Self-reviewed storage/API/Renderer diff against the explicit corrected requirement. No subagent created; Yuxi code was not changed. + +## Follow-ups + +- Main merge awaits a user request for this correction. Keep task worktree/branch for that integration. +- Existing observer task 20260928-agent-observer-design-9c41a872 is separately ready and not included. Authorized coordination was sent to thread 01a0dc9a-9b4e-7fa3-bb19-aea80abaaac4: delivery/opening must use account + project + agent, preserve projectId + stable suggestion.id idempotence, and retain its separate observation runtime. Joint integration must adapt its topic creation and new route/service signatures. +- No cross-device synchronization or runtime cloud deployment verified. Existing unowned historical messages are preserved on disk without guessing a project. + +## Promotion Candidates + +- Targets: current state, ADR-2026-09-22-coding-teacher.md, system-overview.md, data-flow.md and business-rules.md. +- Proposal: correct the previously integrated account-agent global-chat assumption to account-project-agent ongoing chat. Project is the history/draft/read receipt and model-context boundary; Pi source and published version remain per-turn execution boundaries. Integrate the explicit routes/storage and upgrade behavior above. +- Evidence: user's explicit correction, red/green two-project regression, retained-file migration tests, clean cloud checkpoint test, 382 unit tests and three Electron specs. +- Future impact: observer suggestion delivery must target this project's agent chat; bundled clients and Host API ship together. Global-chat checkpoints cannot be reused after partitioning. Preserve old source records. +- Semantic conflict: supersedes the previous single-chat feature's cross-project assumption. User already explicitly resolved this product scope; no new approval needed to accept project isolation. Main merge/deployment still require their corresponding request. diff --git a/README.md b/README.md index 08bcd2c8..28a2e999 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 本地读取使用连续分页:目录按层展开,默认四层、最多六层,跳过 `.gradle` 等生成目录;条目随页面按需产出,深层目录可单独查询;文件和会话默认每页 120 行、最多 400 行,返回连续原文、起始行列与精确 next 游标,列按 Unicode 字符计数、换行统一为 LF。单个云端结果最多 8 KiB,长行也能继续读取;不受文件 UI 的 256 KiB 预览限制。会话索引保留消息 ID 和摘要,按 ID 读取完整公开原文。每题最多十二批、累计成功结果 64 KiB(包含结果元数据),不足 512 字节时收尾;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。本地旧版模型仍按自身上下文和六批额度缩小结果。 - Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。 -- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,同一账号与同一智能体只有一个持续聊天,跨项目保留消息和文字草稿;不再显示新话题按钮或话题选择。每轮接受时固定项目、操作对话与当前发布版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 +- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项。同一账号在同一项目内与同一智能体只有一个持续聊天;不同项目的历史、未发送草稿、未读状态与阅读位置分别保存,切回项目后恢复,不再显示新话题按钮或话题选择。旧版跨项目聊天按每轮保存的项目来源拆入对应聊天,原始文件保留;归属不明的旧消息留在原文件,不分配给任一项目。每轮接受时固定项目、操作对话与当前发布版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 diff --git a/electron/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index 37edfb31..0c8125b0 100644 --- a/electron/api/routes/coding-teacher.ts +++ b/electron/api/routes/coding-teacher.ts @@ -18,7 +18,7 @@ export async function handleCodingTeacherRoutes( ctx: HostApiContext ): Promise { const legacy = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-history(?:\/([^/]+))?$/); - const conversation = url.pathname.match(/^\/api\/coding\/agent-conversations\/([^/]+)(?:\/(messages|events|save|seen|discussion|requests\/([^/]+)\/cancel))?$/); + const conversation = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-conversations\/([^/]+)(?:\/(messages|events|save|seen|discussion|requests\/([^/]+)\/cancel))?$/); const source = url.pathname.match( /^\/api\/coding\/projects\/([^/]+)\/conversations\/([^/]+)\/teacher-topics(?:\/([^/]+))?(?:\/(messages|events|save|discussion|requests\/([^/]+)\/cancel))?$/ ); @@ -56,18 +56,22 @@ export async function handleCodingTeacherRoutes( } : undefined)); return true; } - const agentId = conversation ? decodeURIComponent(conversation[1]) : undefined; - if (agentId && !conversation![2] && req.method === 'GET') { - sendJson(res, 200, await service.conversation(agentId, url.searchParams.get('before') ?? undefined)); + const projectId = conversation ? decodeURIComponent(conversation[1]) : undefined; + const agentId = conversation ? decodeURIComponent(conversation[2]) : undefined; + if (agentId && !conversation![3] && req.method === 'GET') { + sendJson(res, 200, await service.conversation(projectId!, agentId, url.searchParams.get('before') ?? undefined)); return true; } - if (agentId && conversation![2] === 'messages' && req.method === 'POST') { - sendJson(res, 202, await service.sendConversation(agentId, await parseJsonBody(req))); + if (agentId && conversation![3] === 'messages' && req.method === 'POST') { + const input = await parseJsonBody(req); + if (input.projectId && input.projectId !== projectId) + throw new TeacherError(422, 'teacher_project_mismatch', '提问不属于当前项目。'); + sendJson(res, 202, await service.sendConversation(agentId, { ...input, projectId })); return true; } - if (agentId && conversation![2] === 'seen' && req.method === 'POST') { + if (agentId && conversation![3] === 'seen' && req.method === 'POST') { const input = await parseJsonBody<{ requestId: string }>(req); - sendJson(res, 200, await service.markConversationSeen(agentId, input.requestId)); + sendJson(res, 200, await service.markConversationSeen(projectId!, agentId, input.requestId)); return true; } if (checkIn) { @@ -93,16 +97,16 @@ export async function handleCodingTeacherRoutes( sendJson(res, 200, await service.previewDefinition(revision)); return true; } - const continuous = agentId ? await service.conversation(agentId) : undefined; + const continuous = agentId ? await service.conversation(projectId!, agentId) : undefined; if (agentId && !continuous?.topic) throw new TeacherError(404, 'teacher_topic_not_found', '智能体聊天尚未开始。'); - const scope: TeacherScope = agentId ? { projectId: '', sourceId: 'project', agentId } : source + const scope: TeacherScope = agentId ? { projectId: projectId!, sourceId: 'project', agentId } : source ? { projectId: decodeURIComponent(source[1]), sourceId: decodeURIComponent(source[2]) } : projectTopics ? { projectId: decodeURIComponent(projectTopics[1]), sourceId: 'project', role } : { projectId: 'preview', sourceId: 'preview' }; const id = continuous?.topic?.id ?? source?.[3] ?? projectTopics?.[3] ?? preview?.[1], - action = conversation?.[2] ?? source?.[4] ?? projectTopics?.[4] ?? preview?.[2], - requestId = conversation?.[3] ?? source?.[5] ?? projectTopics?.[5] ?? preview?.[3]; + action = conversation?.[3] ?? source?.[4] ?? projectTopics?.[4] ?? preview?.[2], + requestId = conversation?.[4] ?? source?.[5] ?? projectTopics?.[5] ?? preview?.[3]; if (!id && req.method === 'GET') { sendJson(res, 200, await service.list(scope)); return true; diff --git a/electron/coding-teacher/conversation-store.ts b/electron/coding-teacher/conversation-store.ts index 7bcd1847..6a5d22be 100644 --- a/electron/coding-teacher/conversation-store.ts +++ b/electron/coding-teacher/conversation-store.ts @@ -12,6 +12,7 @@ interface Manifest { topic: Omit; turns: TurnIndex[]; importedDiscussions?: Record; + importedGlobal?: boolean; } const PAGE_SIZE = 50; const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === 'ENOENT'; @@ -28,7 +29,8 @@ export class TeacherConversationStore { private loaded?: Promise; private writes: Promise = Promise.resolve(); private readonly importedProjects = new Set(); - constructor(readonly directory: string, readonly accountId: string, readonly agentId: string) {} + private checkedGlobal = false; + constructor(readonly directory: string, readonly accountId: string, readonly agentId: string, readonly projectId: string) {} private manifestPath() { return path.join(this.directory, 'conversation.json'); } private turnPath(id: string) { return path.join(this.directory, 'turns', teacherTopicId(id) + '.json'); } @@ -41,7 +43,7 @@ export class TeacherConversationStore { catch (error) { if (isMissing(error)) return; throw error; } const manifest = this.manifest; if (manifest.topic.accountId !== this.accountId || manifest.topic.conversation?.agentId !== this.agentId - || !Array.isArray(manifest.turns)) + || manifest.topic.projectId !== this.projectId || !Array.isArray(manifest.turns)) throw new TeacherError(409, 'teacher_history_invalid', '智能体历史无法读取,请保留本机记录。'); // A turn can finish writing just before the index write is interrupted. // Recover only unindexed files, without reading every historical body. @@ -84,7 +86,7 @@ export class TeacherConversationStore { const now = new Date().toISOString(); const topic: TeacherTopic = { schemaVersion: 1, revision: 0, id: randomUUID(), accountId: this.accountId, - projectId: '', sourceConversationId: 'project', definition: structuredClone(definition), version, + projectId: this.projectId, sourceConversationId: 'project', definition: structuredClone(definition), version, createdAt: now, updatedAt: now, requests: [], conversation: { agentId: this.agentId, segmentTurns: 0, discussions: {} }, }; @@ -151,7 +153,8 @@ export class TeacherConversationStore { } turns.sort(order); const { requests: _requests, unsaved: _unsaved, ...metadata } = topic; - const next = { topic: metadata, turns, importedDiscussions: this.manifest.importedDiscussions }; + const next = { topic: metadata, turns, importedDiscussions: this.manifest.importedDiscussions, + importedGlobal: this.manifest.importedGlobal }; try { await atomicWriteJson(this.manifestPath(), next); } catch (error) { // The body may already be durable. Reload and recover it before a retry, @@ -169,7 +172,7 @@ export class TeacherConversationStore { topic.requests = (await this.page()).requests; return topic; } - async select(_id: string) { /* There is only one conversation per agent. */ } + async select(_id: string) { /* One conversation per project and agent. */ } async list() { await this.load(); const topic = this.live; @@ -185,8 +188,64 @@ export class TeacherConversationStore { topic.revision++; await this.save(topic, ''); } - /** Old files remain untouched. Only a proven account + config ID can be imported. */ + /** Split the former account-wide chat using recorded turn ownership; never mutate its files. */ + async importGlobal(directory: string) { + await this.load(); + if (this.checkedGlobal || this.manifest?.importedGlobal) return; + let old: Manifest; + try { old = await readJsonFile(path.join(directory, 'conversation.json')) as Manifest; } + catch (error) { if (isMissing(error)) { this.checkedGlobal = true; return; } throw error; } + if (old.topic.accountId !== this.accountId || old.topic.conversation?.agentId !== this.agentId) { + this.checkedGlobal = true; return; + } + const ids = new Set(old.turns.map(turn => turn.id)); + // Include a body whose original index write was interrupted. + try { + for (const name of await readdir(path.join(directory, 'turns'))) + if (/^[0-9a-f-]{36}\.json$/i.test(name)) ids.add(name.slice(0, -5)); + } catch (error) { if (!isMissing(error)) throw error; } + const seenIndex = old.turns.findIndex(turn => turn.id === old.topic.conversation?.seenThrough); + const seenIds = new Set(old.turns.slice(0, seenIndex + 1).map(turn => turn.id)); + let seen: string | undefined; + for (const id of ids) { + const request = await readJsonFile(path.join(directory, 'turns', teacherTopicId(id) + '.json')) as TeacherRequest; + if ((request.projectId ?? request.origin?.projectId) !== this.projectId) continue; + const topic = await this.ensure(old.topic.definition, old.topic.version); + if (seenIds.has(id)) seen = id; + if (this.manifest!.turns.some(turn => turn.id === id)) continue; + const turn: TeacherRequest = { ...structuredClone(request), projectId: this.projectId, + // A split chat must start a new cloud checkpoint, since the old one may contain other projects. + origin: request.origin ?? { projectId: this.projectId, topicId: old.topic.id, requestId: id }, + ...(old.topic.definition.runtime === 'yuxi' ? { cloudRequestId: request.cloudRequestId ?? id } : {}) }; + if (turn.status === 'running' || turn.status === 'preparing') { + turn.status = 'interrupted'; turn.error = '项目聊天已分开,本次回复中断。'; + } + topic.requests.push(turn); + topic.updatedAt = [topic.updatedAt, turn.createdAt].sort().at(-1)!; + topic.revision++; + await this.save(topic, turn.id); + topic.requests = topic.requests.slice(-PAGE_SIZE); + } + // Retain an empty project's discussion too, without assigning unowned turns to it. + const discussion = old.topic.conversation.discussions[this.projectId]; + if (discussion) { + const topic = await this.ensure(old.topic.definition, old.topic.version); + if (!topic.conversation!.discussions[this.projectId]) { + topic.conversation!.discussions[this.projectId] = structuredClone(discussion); + (this.manifest!.importedDiscussions ??= {})[this.projectId] = old.topic.updatedAt; + } + } + if (this.manifest) { + if (seen) await this.markSeen(seen); + this.manifest.importedGlobal = true; + await this.save(await this.read(), ''); + await this.recent(); + } + this.checkedGlobal = true; + } + /** Old files remain untouched. Only a proven account + project + config ID can be imported. */ async importProject(project: { id: string; path: string; name: string }) { + if (project.id !== this.projectId) throw new Error('Conversation project mismatch'); if (this.importedProjects.has(project.id)) return; await this.load(); const root = path.join(project.path, '.makelore', 'teacher-conversations', this.accountId); diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 53ff6e6d..da496d1c 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -135,31 +135,32 @@ export class CodingTeacherService { this.assertAccount(account); return { items: items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), lastSelectedTopicId: null }; } - private conversationStore(account: TeacherAccount, agentId: string) { + private conversationStore(account: TeacherAccount, projectId: string, agentId: string) { if (!agentId || agentId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new TeacherError(400, 'teacher_agent_invalid', '智能体标识无效。'); - const key = account.id + ':' + agentId; + teacherTopicId(projectId); + const key = account.id + ':' + projectId + ':' + agentId; let store = this.conversations.get(key); if (!store) { - store = new TeacherConversationStore(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId), account.id, agentId); + store = new TeacherConversationStore(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId, 'projects', projectId), account.id, agentId, projectId); this.conversations.set(key, store); } return store; } - private async importConversations(account: TeacherAccount, agentId: string) { - const store = this.conversationStore(account, agentId); - if (![...this.active.keys()].some(key => key.startsWith(account.id + ':agent:' + agentId + ':'))) { - for (const project of await this.options.projects.listProjects()) { - this.assertAccount(account); - await store.importProject(project); - } + private async importConversations(account: TeacherAccount, projectId: string, agentId: string) { + const project = await this.options.projects.getProject(projectId); + const store = this.conversationStore(account, projectId, agentId); + if (![...this.active.keys()].some(key => key.startsWith(account.id + ':agent:' + projectId + ':' + agentId + ':'))) { + this.assertAccount(account); + await store.importGlobal(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId)); + await store.importProject(project); } return store; } - async conversation(agentId: string, before?: string) { + async conversation(projectId: string, agentId: string, before?: string) { const account = await this.account(); - return this.serialize(account.id + ':agent:' + agentId + ':acceptance', async () => { - const store = await this.importConversations(account, agentId); + return this.serialize(this.acceptanceKey(account, { projectId, agentId, sourceId: 'project' }), async () => { + const store = await this.importConversations(account, projectId, agentId); if (!await store.exists()) return { topic: null, before: null }; const topic = await store.read(); const page = await store.page(before); @@ -172,7 +173,7 @@ export class CodingTeacherService { const account = await this.account(); const scope: TeacherScope = { projectId: input.projectId, sourceId: 'project', agentId }; return this.serialize(this.acceptanceKey(account, scope), async () => { - const store = await this.importConversations(account, agentId); + const store = await this.importConversations(account, scope.projectId, agentId); if (!await store.exists()) { const catalog = await (this.options.catalog ?? teacherCatalog)(account); const selected = catalog.items.find(item => item.teacher_id === agentId); @@ -185,11 +186,11 @@ export class CodingTeacherService { return this.sendRequest(account, scope, topic.id, input); }); } - async markConversationSeen(agentId: string, requestId: string) { + async markConversationSeen(projectId: string, agentId: string, requestId: string) { const account = await this.account(); - const scope = { agentId, projectId: '', sourceId: 'project' }; + const scope = { agentId, projectId, sourceId: 'project' }; return this.serialize(this.acceptanceKey(account, scope), async () => { - const store = this.conversationStore(account, agentId); + const store = this.conversationStore(account, projectId, agentId); this.assertAccount(account); await store.markSeen(requestId); const topic = await store.read(); @@ -201,7 +202,7 @@ export class CodingTeacherService { account: TeacherAccount, scope: TeacherScope ): Promise { - if (scope.agentId) return this.conversationStore(account, scope.agentId); + if (scope.agentId) return this.conversationStore(account, scope.projectId, scope.agentId); if (this.deletingSources.has(scope.projectId + ':' + scope.sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); let directory: string; @@ -227,11 +228,11 @@ export class CodingTeacherService { return store; } private key(account: TeacherAccount, scope: TeacherScope, id: string) { - if (scope.agentId) return account.id + ':agent:' + scope.agentId + ':' + id; + if (scope.agentId) return account.id + ':agent:' + scope.projectId + ':' + scope.agentId + ':' + id; return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id; } private acceptanceKey(account: TeacherAccount, scope: TeacherScope) { - if (scope.agentId) return account.id + ':agent:' + scope.agentId + ':acceptance'; + if (scope.agentId) return this.key(account, scope, 'acceptance'); return account.id + ':' + scope.projectId + ':teacher-acceptance'; } private async serialize(key: string, operation: () => Promise): Promise { @@ -317,8 +318,8 @@ export class CodingTeacherService { const topic = (await store.read(id)) as PreviewTopic; if ( topic.accountId !== account.id || - (scope.agentId ? topic.conversation?.agentId !== scope.agentId : - topic.projectId !== scope.projectId || topic.sourceConversationId !== scope.sourceId) || + topic.projectId !== scope.projectId || + (scope.agentId ? topic.conversation?.agentId !== scope.agentId : topic.sourceConversationId !== scope.sourceId) || (topic.role ?? 'teacher') !== (scope.role ?? 'teacher') ) throw new TeacherError(404, 'teacher_topic_not_found', '智能体话题不存在。'); @@ -712,6 +713,7 @@ export class CodingTeacherService { const next = structuredClone(topic); if (next.conversation) { if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请选择讨论所属的项目。'); + if (input.projectId !== scope.projectId) throw new TeacherError(422, 'teacher_project_mismatch', '讨论不属于当前项目。'); await this.options.projects.getProject(input.projectId); next.discussion = next.conversation.discussions[input.projectId]; } diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index b4f92c05..c82a1ac2 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -122,7 +122,7 @@ export interface TeacherToolActivity { status: 'running' | 'completed' | 'failed'; } export interface TeacherTopic { - /** Present only for the account-owned continuous conversation. */ + /** Present only for the continuous conversation owned by this account + project + agent. */ conversation?: { agentId: string; runtimeThreadId?: string; diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index 278af0db..cb123c28 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -20,6 +20,9 @@ export function teacherTopicsPath(projectId: string) { ? '/api/coding/teacher-preview/topics' : `/api/coding/projects/${encodeURIComponent(projectId)}/agent-topics`; } +export function teacherConversationsPath(projectId: string) { + return `/api/coding/projects/${encodeURIComponent(projectId)}/agent-conversations`; +} /** Read old locally-generated friend histories without copying or rebinding them. */ export function legacyTopicBase(base: string, legacyFriend: boolean) { return legacyFriend ? base.replace(/agent-topics$/, 'friend-topics') : base; @@ -28,12 +31,12 @@ export const teacherApi = { legacyHistory: (projectId: string) => hostApiFetch(`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history`), legacyRead: (projectId: string, item: TeacherTopicList['items'][number]) => hostApiFetch( `/api/coding/projects/${encodeURIComponent(projectId)}/agent-history/${encodeURIComponent(item.id)}?source=${encodeURIComponent(item.legacySourceId ?? 'project')}&friend=${item.legacyRole === 'friend'}`), - seenConversation: (agentId: string, requestId: string) => hostApiFetch( - '/api/coding/agent-conversations/' + encodeURIComponent(agentId) + '/seen', { method: 'POST', body: JSON.stringify({ requestId }) }), - conversation: (agentId: string, before?: string) => hostApiFetch( - '/api/coding/agent-conversations/' + encodeURIComponent(agentId) + (before ? '?before=' + encodeURIComponent(before) : '')), - sendConversation: (agentId: string, input: TeacherSend) => hostApiFetch( - '/api/coding/agent-conversations/' + encodeURIComponent(agentId) + '/messages', { method: 'POST', body: JSON.stringify(input) }), + seenConversation: (projectId: string, agentId: string, requestId: string) => hostApiFetch( + teacherConversationsPath(projectId) + '/' + encodeURIComponent(agentId) + '/seen', { method: 'POST', body: JSON.stringify({ requestId }) }), + conversation: (projectId: string, agentId: string, before?: string) => hostApiFetch( + teacherConversationsPath(projectId) + '/' + encodeURIComponent(agentId) + (before ? '?before=' + encodeURIComponent(before) : '')), + sendConversation: (projectId: string, agentId: string, input: TeacherSend) => hostApiFetch( + teacherConversationsPath(projectId) + '/' + encodeURIComponent(agentId) + '/messages', { method: 'POST', body: JSON.stringify(input) }), catalog: () => hostApiFetch('/api/coding/teacher/teachers'), checkIn: (projectId: string, input: TeacherCheckInInput) => hostApiFetch(`/api/coding/projects/${encodeURIComponent(projectId)}/teacher-check-in`, { diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 7fe9dd00..d63d897b 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -969,7 +969,7 @@ export function CodingChatPanel({ onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} /> - request.origin ? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : request.id; -export function TeacherChatPanel({ +export function TeacherChatPanel(props: Parameters[0]) { + const account = useAuthStore(state => state.user?.userId ?? state.user?.username ?? 'signed-out'); + return ; +} + +function ProjectTeacherChatPanel({ projectId, projectName, sourceId, @@ -51,15 +56,21 @@ export function TeacherChatPanel({ reloadCatalog?(): Promise; }) { const continuous = Boolean(selectedAgent && !draftRevision); - const base = continuous ? '/api/coding/agent-conversations' : teacherTopicsPath(projectId); + const base = continuous ? teacherConversationsPath(projectId) : teacherTopicsPath(projectId); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`; - const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null; + const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:project:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null; const [cachedView] = useState(() => continuous && draftKey ? conversationViews.get(draftKey) : undefined); const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend; projectId?: string; sourceId?: string }>(() => { try { - const legacyKey = selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey; - return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? localStorage.getItem(legacyKey) ?? '{}') : {}; + if (!draftKey) return {}; + const current = localStorage.getItem(draftKey); + if (current) return JSON.parse(current); + const legacyKey = selectedAgent ? `makelore-consultation-draft:${account}:agent:${selectedAgent.teacher_id}` : projectDraftKey; + const old = JSON.parse(localStorage.getItem(legacyKey) ?? '{}'); + if ((old.projectId ?? old.pending?.projectId) === projectId) return old; + // Pre-single-chat versions already had project drafts; retain them as a fallback. + return selectedAgent ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}`) ?? '{}') : {}; } catch { return {}; } }); const [referenceScope, setReferenceScope] = useState({ projectId: saved.projectId ?? projectId, sourceId: saved.sourceId ?? sourceId }); @@ -98,7 +109,7 @@ export function TeacherChatPanel({ const [topics, setTopics] = useState({ items: [], lastSelectedTopicId: null }); const [storedTopic, setTopic] = useState(cachedView?.topic ?? null); const topic = useMemo(() => storedTopic && continuous ? { ...storedTopic, - discussion: storedTopic.projectId === projectId ? storedTopic.discussion : storedTopic.conversation?.discussions[projectId] } + discussion: storedTopic.conversation?.discussions[projectId] ?? storedTopic.discussion } : storedTopic, [storedTopic, continuous, projectId]); const legacyFriend = topic?.role === 'friend'; const legacyView = Boolean(continuous && topic && !topic.conversation); @@ -165,6 +176,7 @@ export function TeacherChatPanel({ }, [topic?.id, topic?.revision, topic?.requests.length]); const consume = useCallback( (next: TeacherTopic) => { + if (next.projectId !== projectId) return; if (pending.current && next.requests.some((request) => request.id === pending.current?.requestId)) { pending.current = null; persistDraft(draftRef.current.text, draftRef.current.references); @@ -178,10 +190,10 @@ export function TeacherChatPanel({ return { ...(olderSnapshot ? current : next), requests: [...requests.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || messageOrderKey(a).localeCompare(messageOrderKey(b))) }; }); }, - [persistDraft, continuous] + [persistDraft, continuous, projectId] ); useEffect(() => { - if (!viewingLegacy.current && externalTopic && (continuous || externalTopic.projectId === projectId) + if (!viewingLegacy.current && externalTopic && externalTopic.projectId === projectId && (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) { consume(externalTopic); setDefinition(continuous ? selectedAgentRef.current?.definition ?? externalTopic.definition : externalTopic.definition); @@ -235,7 +247,7 @@ export function TeacherChatPanel({ } try { if (continuous && selectedAgentId) { - const view = await teacherApi.conversation(selectedAgentId); + const view = await teacherApi.conversation(projectId, selectedAgentId); if (alive) { if (view.topic) consume(view.topic); if (!cachedView?.topic) setBefore(view.before); } return; } @@ -245,7 +257,7 @@ export function TeacherChatPanel({ const previous = listed.items.find((item) => item.id === listed.lastSelectedTopicId && (!selectedAgentId || item.teacherId === selectedAgentId)) ?? (selectedAgentId ? listed.items.find((item) => item.teacherId === selectedAgentId) : undefined); - if (externalTopicRef.current && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) { + if (externalTopicRef.current?.projectId === projectId && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) { consume(externalTopicRef.current); setDefinition(externalTopicRef.current.definition); } else if (previous) { @@ -267,7 +279,7 @@ export function TeacherChatPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps generation.current++; }; - }, [base, draftRevision, label, consume, selectedAgentId, continuous, cachedView, reloadCatalog]); + }, [base, projectId, draftRevision, label, consume, selectedAgentId, continuous, cachedView, reloadCatalog]); const seenPending = useRef(undefined); const markSeen = useCallback(() => { const current = topicRef.current; @@ -276,9 +288,9 @@ export function TeacherChatPanel({ || document.visibilityState === 'hidden' || ['preparing', 'running'].includes(last.status) || current.conversation?.seenThrough === last.id || seenPending.current === last.id) return; seenPending.current = last.id; - void teacherApi.seenConversation(selectedAgentId, last.id).then(consume).catch(() => undefined) + void teacherApi.seenConversation(projectId, selectedAgentId, last.id).then(consume).catch(() => undefined) .finally(() => { seenPending.current = undefined; }); - }, [continuous, legacyView, selectedAgentId, consume]); + }, [continuous, legacyView, projectId, selectedAgentId, consume]); useEffect(() => { markSeen(); }, [topic?.revision, markSeen]); useEffect(() => { if (!topic?.id || legacyFriend || legacyView) return; @@ -318,7 +330,7 @@ export function TeacherChatPanel({ const epoch = generation.current; setLoadingOlder(true); try { - const page = await teacherApi.conversation(selectedAgentId, before); + const page = await teacherApi.conversation(projectId, selectedAgentId, before); if (epoch !== generation.current) return; const pane = scrollRef.current; if (pane) olderAnchor.current = { height: pane.scrollHeight, top: pane.scrollTop }; @@ -336,7 +348,7 @@ export function TeacherChatPanel({ }; const returnToConversation = async () => { if (!selectedAgentId) return; - const view = await teacherApi.conversation(selectedAgentId); + const view = await teacherApi.conversation(projectId, selectedAgentId); viewingLegacy.current = false; setTopic(view.topic); setBefore(view.before); setDefinition(selectedAgentRef.current?.definition ?? null); setHistoryOpen(false); @@ -436,7 +448,7 @@ export function TeacherChatPanel({ }; pending.current = input; persistDraft(text, references, input); - const next = continuous ? await teacherApi.sendConversation(selectedAgentId!, input) + const next = continuous ? await teacherApi.sendConversation(projectId, selectedAgentId!, input) : await teacherApi.send(topicBase, current!.id, input); if (version !== generation.current) return; consume(next); diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 44302560..af6b7758 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { teacherApi, teacherTopicsPath, type TeacherCatalogState } from '@/lib/coding-teacher'; +import { teacherApi, teacherTopicsPath, teacherConversationsPath, type TeacherCatalogState } from '@/lib/coding-teacher'; import { useAuthStore } from '@/stores/auth'; import { AppError } from '@/lib/error-model'; import { @@ -50,7 +50,7 @@ function unreadInvitation(state: Pick) { export function useTeacherCompanion(options: Options) { const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); - const scope = account; + const scope = account + ':' + (options.projectId ?? 'no-project'); const storageKey = `makelore-teacher-presence:${scope}`; const [state, setState] = useState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' }); const [unreadAgentIds, setUnreadAgentIds] = useState([]); @@ -88,7 +88,7 @@ export function useTeacherCompanion(options: Options) { return () => window.clearTimeout(timer); }, [state.refreshStatus]); const noteTopic = useCallback((topic: TeacherTopic) => { - if (scopeRef.current !== scope || (!topic.conversation && topic.projectId !== latest.current.options.projectId)) return; + if (scopeRef.current !== scope || topic.projectId !== latest.current.options.projectId) return; const selectedId = latest.current.state.selectedAgent?.teacher_id; if (selectedId && topic.definition.config_id !== selectedId) return; if (topic.conversation) { @@ -133,7 +133,7 @@ export function useTeacherCompanion(options: Options) { const storedSeen = readLocal(storageKey + ':seen', []); const seen = Array.isArray(storedSeen) ? storedSeen.filter((id): id is string => typeof id === 'string') : []; const welcomeDismissed = readLocal(storageKey + ':welcome-dismissed', false) === true; - if (!preserveChat) selectionEpoch.current++; + if (!preserveChat) { selectionEpoch.current++; setUnreadAgentIds([]); } const initialSelection = selectionEpoch.current; if (!preserveChat) setState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen, welcomeDismissed, error: '' }); else setState((current) => ({ ...current, refreshing: false, refreshStatus: 'idle' })); @@ -152,7 +152,7 @@ export function useTeacherCompanion(options: Options) { const selected = catalog.items.find((item) => item.teacher_id === selectedId) ?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined); const unassigned = current.selectedAgent === null - || (!current.agents.length && !selectedId && latest.current.options.teacherOpen); + || (!current.loading && !current.agents.length && !selectedId && latest.current.options.teacherOpen); return { selectedId, selected, unassigned, enabled: Boolean(selected) || (!selectedId && config.enabled) }; }; @@ -175,8 +175,8 @@ export function useTeacherCompanion(options: Options) { const loaded = await loadConfig(); if (!alive) return; const agentId = loaded.selected?.teacher_id ?? loaded.selectedId; - if (agentId) { - const view = await teacherApi.conversation(agentId); + if (agentId && projectId) { + const view = await teacherApi.conversation(projectId, agentId); if (alive && view.topic && initialSelection === selectionEpoch.current) noteTopic(view.topic); return; } @@ -206,14 +206,14 @@ export function useTeacherCompanion(options: Options) { inFlight.current = true; try { const refreshed = currentState.topic.conversation - ? (await teacherApi.conversation(currentState.topic.conversation.agentId)).topic + ? (await teacherApi.conversation(currentState.topic.projectId, currentState.topic.conversation.agentId)).topic : await teacherApi.read(base, currentState.topic.id, false); if (alive && refreshed && latest.current.state.topic?.id === currentState.topic.id) noteTopic(refreshed); } catch { /* The next visible tick retries this read without dispatching a model. */ } finally { if (alive) inFlight.current = false; } return; } - // Delivered agents now own one account-wide chat. Project observations + // Delivered agents own one chat per project. Project observations // have their own Main scheduler; this legacy timer must not create topics. if (currentState.selectedAgent || currentState.agents.length) { try { await loadConfig(); } catch { /* Keep last known delivered contacts. */ } @@ -266,17 +266,19 @@ export function useTeacherCompanion(options: Options) { useEffect(() => { let alive = true; + const projectId = options.projectId; + if (!projectId) return; const refresh = async () => { if (document.visibilityState === 'hidden') return; const views = await Promise.allSettled(state.agents.map(async agent => ({ - id: agent.teacher_id, view: await teacherApi.conversation(agent.teacher_id), + id: agent.teacher_id, view: await teacherApi.conversation(projectId, agent.teacher_id), }))); if (!alive) return; setUnreadAgentIds(views.flatMap(result => { if (result.status !== 'fulfilled') return []; const { id, view } = result.value; const last = view.topic?.requests.at(-1); - return last && !['preparing', 'running'].includes(last.status) + return view.topic?.projectId === projectId && last && !['preparing', 'running'].includes(last.status) && view.topic?.conversation?.seenThrough !== last.id ? [id] : []; })); }; @@ -284,18 +286,18 @@ export function useTeacherCompanion(options: Options) { const timer = window.setInterval(() => void refresh(), 15_000); window.addEventListener('focus', refresh); return () => { alive = false; window.clearInterval(timer); window.removeEventListener('focus', refresh); }; - }, [account, state.agents]); + }, [account, options.projectId, state.agents]); const topic = stateScope.current === scope ? state.topic : null; const topicId = topic?.id; const topicProjectId = topic?.projectId; const conversationAgentId = topic?.conversation?.agentId; useEffect(() => { - if (!topicId || (!topicProjectId && !conversationAgentId) || topic?.role === 'friend') return; + if (!topicId || !topicProjectId || topic?.role === 'friend') return; let alive = true; let stream: EventSource | undefined; const streamSelection = selectionEpoch.current; - const base = conversationAgentId ? '/api/coding/agent-conversations' : teacherTopicsPath(topicProjectId!); + const base = conversationAgentId ? teacherConversationsPath(topicProjectId) : teacherTopicsPath(topicProjectId); void teacherApi.events(base, conversationAgentId ?? topicId).then((source) => { if (!alive) { source.close(); return; } stream = source; @@ -318,7 +320,7 @@ export function useTeacherCompanion(options: Options) { const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed ? selectedDefinition?.welcome_message.trim() || null : null; return { - unreadAgentIds, + unreadAgentIds: stateScope.current === scope ? unreadAgentIds : [], ...visibleState, error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error, definition: selectedDefinition, diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 41d654f8..620aafc9 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -96,9 +96,10 @@ async function installCodingFirstChatHost( managedCapabilities = false, removedModel = false, audioPreview?: { executionId: string; path: string; dataUrl: string }, + multipleProjects = false, ): Promise { await electronApp.evaluate(async (_, payload) => { - const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload; + const { connection, featureComplete, managedCapabilities, removedModel, audioPreview, multipleProjects } = payload; const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); type MainState = TeacherCatalogFixture & { captured: CapturedRequest[]; @@ -139,6 +140,8 @@ async function installCodingFirstChatHost( updatedAt: now, lastOpenedAt: now, }; + const otherProject = { ...project, id: 'project-pi-other', name: 'Another project' }; + let activeProject = project; const configuredModel = { accountId: managedCapabilities ? 'niancode-user-models' : 'account-e2e', modelId: 'model-a', @@ -560,21 +563,21 @@ async function installCodingFirstChatHost( return respond(state.teacherCatalog); } if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition}); - const agentChat = path.match(/^\/api\/coding\/agent-conversations\/([^/?]+)(?:\/(messages|seen))?(?:\?.*)?$/); + const agentChat = path.match(/^\/api\/coding\/projects\/([^/?]+)\/agent-conversations\/([^/?]+)(?:\/(messages|seen))?(?:\?.*)?$/); if (agentChat) { - const agentId = agentChat[1], action = agentChat[2]; + const projectId = agentChat[1], agentId = agentChat[2], action = agentChat[3], chatKey = projectId + ':' + agentId; const selected = state.teacherCatalog.items.find(item => item.teacher_id === agentId)!; - const current = consultationTopics[agentId] ?? {schemaVersion:1,revision:0,id:'chat-' + agentId,accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[],conversation:{agentId,segmentTurns:0,discussions:{}}}; - if (!action && method === 'GET') return respond({topic: consultationTopics[agentId] ?? null, before:null}); + const current = consultationTopics[chatKey] ?? {schemaVersion:1,revision:0,id:'chat-' + chatKey,accountId:'e2e',projectId,sourceConversationId:'project',definition:selected?.definition ?? teacherDefinition,version:selected?.version ?? 1,createdAt:now,updatedAt:now,requests:[],conversation:{agentId,segmentTurns:0,discussions:{}}}; + if (!action && method === 'GET') return respond({topic: consultationTopics[chatKey] ?? null, before:null}); if (action === 'seen') { - consultationTopics[agentId] = {...current, revision:Number(current.revision)+1,conversation:{...current.conversation as object,seenThrough:body!.requestId}}; - return respond(consultationTopics[agentId]); + consultationTopics[chatKey] = {...current, revision:Number(current.revision)+1,conversation:{...current.conversation as object,seenThrough:body!.requestId}}; + return respond(consultationTopics[chatKey]); } if (action === 'messages' && method === 'POST') { const suggestions = body!.intent === 'suggestions'; const response = suggestions ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。'; - consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]}; - return respond(consultationTopics[agentId],202); + consultationTopics[chatKey] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]}; + return respond(consultationTopics[chatKey],202); } } const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1]; @@ -597,8 +600,19 @@ async function installCodingFirstChatHost( if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic); } if (path === '/api/coding/projects') { - return respond({ projects: [project], activeProjectId: project.id }); + return respond({ projects: multipleProjects ? [project, otherProject] : [project], activeProjectId: activeProject.id }); } + if (path === '/api/coding/projects/active' && method === 'POST') { + activeProject = body!.projectId === otherProject.id ? otherProject : project; + return respond({ project: activeProject }); + } + if (multipleProjects && path === `/api/coding/projects/config?projectId=${otherProject.id}`) + return respond({ snapshot: { project: otherProject, config, knowledgeFiles: [] } }); + if (multipleProjects && path === `/api/coding/projects/conversations?projectId=${otherProject.id}`) + return respond({ conversations: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] }); + if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot') + return respond({ snapshot: { ...secondSnapshot, conversation: { ...secondSnapshot.conversation, + id: 'conversation-other-project', projectId: otherProject.id } } }); if (path === '/api/provider-accounts') { return respond([{ id: managedCapabilities ? 'niancode-user-models' : 'account-e2e', @@ -836,7 +850,7 @@ async function installCodingFirstChatHost( if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } }); return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404); }); - }, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview }); + }, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview, multipleProjects }); } test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => { @@ -1888,7 +1902,7 @@ test('project consultations preserve student drafts and switch between work and const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST'); expect(consultationRequests).toHaveLength(3); expect(requests.filter(item => item.path.endsWith('/agent-topics') && item.method === 'POST')).toHaveLength(0); - expect(consultationRequests.every(item => item.path === '/api/coding/agent-conversations/teacher-algorithm/messages')).toBe(true); + expect(consultationRequests.every(item => item.path === '/api/coding/projects/project-pi-first-chat/agent-conversations/teacher-algorithm/messages')).toBe(true); await expect(teacher.getByRole('button', { name: '智能体新话题' })).toHaveCount(0); await expect(teacher.getByRole('combobox')).toHaveCount(0); await expect(teacher).toHaveAccessibleName('朋友咨询'); @@ -1905,6 +1919,56 @@ test('project consultations preserve student drafts and switch between work and }); +test('the same delivered agent keeps separate project chats and drafts across project switches and reload', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + const connection = await page.evaluate(async () => ({ + token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, + baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string, + })); + await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, true); + await settleSnapshot(electronApp); + await disableCodingEventSource(page); + try { + await page.reload(); page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + await expect(page.getByTestId('project-conversations')).toBeVisible(); + await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click(); + const panel = page.getByTestId('teacher-chat-panel'); + const input = panel.getByRole('textbox', { name: '向智能体提问' }); + await input.fill('天气项目的独立问题'); + await panel.getByRole('button', { name: '提问', exact: true }).click(); + await expect(panel.getByText('天气项目的独立问题', { exact: true })).toBeVisible(); + await input.fill('天气项目还未发送'); + await page.getByRole('button', { name: '进入项目 Another project', exact: true }).click(); + await expect(page.getByTestId('project-conversations').getByText('Other project conversation', { exact: true })).toBeVisible(); + // Switching a project may close the consultation dock; reopening reads this project's chat only. + if (!await panel.isVisible()) await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click(); + await expect(input).toHaveValue(''); + await expect(panel.getByText('天气项目的独立问题', { exact: true })).toHaveCount(0); + await input.fill('游戏项目的独立问题'); + await panel.getByRole('button', { name: '提问', exact: true }).click(); + await expect(panel.getByText('游戏项目的独立问题', { exact: true })).toBeVisible(); + await input.fill('游戏项目还未发送'); + await page.getByRole('button', { name: '进入项目 PI first chat', exact: true }).click(); + if (!await panel.isVisible()) await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click(); + await expect(input).toHaveValue('天气项目还未发送'); + await expect(panel.getByText('天气项目的独立问题', { exact: true })).toBeVisible(); + await expect(panel.getByText('游戏项目的独立问题', { exact: true })).toHaveCount(0); + await page.reload(); page = await getStableWindow(electronApp); + if (!await panel.isVisible()) await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click(); + await expect(input).toHaveValue('天气项目还未发送'); + await expect(panel.getByText('天气项目的独立问题', { exact: true })).toBeVisible(); + await expect(panel.getByRole('combobox')).toHaveCount(0); + const sends = (await readState(electronApp)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST'); + expect(sends.map(item => item.body?.projectId)).toEqual(['project-pi-first-chat', 'project-pi-other']); + expect(sends[0].path).toBe('/api/coding/projects/project-pi-first-chat/agent-conversations/teacher-code/messages'); + expect(sends[1].path).toBe('/api/coding/projects/project-pi-other/agent-conversations/teacher-code/messages'); + await page.screenshot({ path: test.info().outputPath('project-scoped-agent-chat.png') }); + } finally { await releaseSnapshot(electronApp); } +}); + test('manual agent refresh updates the catalog and retries failures without replacing the ongoing chat or drafts', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index 9b1f3b62..cad58d52 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -20,6 +20,7 @@ const api = vi.hoisted(() => ({ })); vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, + teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations', teacherTopicsPath: (p: string) => p === 'preview' ? 'preview/sample' : p + '/agent-topics', legacyTopicBase: (base: string, legacy: boolean) => legacy ? base.replace('agent-topics', 'friend-topics') : base, })); @@ -696,7 +697,7 @@ describe('merged cloud teacher classroom', () => { const selected = { ...topic('selected'), sourceConversationId: 'project', role: 'teacher' as const, definition: cloudDefinition, version: 9 }; api.conversation.mockResolvedValue({ topic: null, before: null }); api.create.mockResolvedValue(selected); - api.sendConversation.mockImplementation(async (_agent, input: TeacherSend) => ({ ...selected, revision: 2, + api.sendConversation.mockImplementation(async (_project, _agent, input: TeacherSend) => ({ ...selected, revision: 2, conversation: {agentId: 'algorithm', segmentTurns: 1, discussions: {}, seenThrough: input.requestId}, requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] })); render(); @@ -709,7 +710,7 @@ describe('merged cloud teacher classroom', () => { fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('先看看玩家会做什么。'); expect(api.create).not.toHaveBeenCalled(); - expect(api.sendConversation).toHaveBeenCalledWith('algorithm', expect.objectContaining({ projectId: 'p', sourceConversationId: 'c', presentation: 'discussion-v1' })); + expect(api.sendConversation).toHaveBeenCalledWith('p', 'algorithm', expect.objectContaining({ projectId: 'p', sourceConversationId: 'c', presentation: 'discussion-v1' })); expect(screen.getByRole('complementary', { name: '算法智能体咨询' })).toBeVisible(); }); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 720534f5..a6726a14 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -7,7 +7,8 @@ import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher'; const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() })); -vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project })); +vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project, + teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations' })); vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) })); const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [], @@ -89,7 +90,7 @@ describe('manual agent refresh', () => { await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); expect(api.create).not.toHaveBeenCalled(); expect(api.send).not.toHaveBeenCalled(); - expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ projectId: 'project', sourceConversationId: 'source', text: '我正在写的问题' })); + expect(api.sendConversation).toHaveBeenCalledWith('project', original.teacher_id, expect.objectContaining({ projectId: 'project', sourceConversationId: 'source', text: '我正在写的问题' })); }); it('preserves the ongoing chat, old turn version and draft across refresh and later removal', async () => { @@ -100,7 +101,7 @@ describe('manual agent refresh', () => { requests: [{ ...checkInTopic().requests[0], teacherVersion: 1 }], conversation: { agentId: original.teacher_id, segmentTurns: 1, discussions: {}, seenThrough: 'check-1' } }; api.catalog.mockResolvedValue({ items: [original] }); - api.conversation.mockImplementation(async (id) => ({ topic: id === original.teacher_id ? history : null, before: null })); + api.conversation.mockImplementation(async (project, id) => ({ topic: project === 'project' && id === original.teacher_id ? history : null, before: null })); const view = await mount(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); const input = screen.getByLabelText('向智能体提问'); @@ -122,16 +123,19 @@ describe('manual agent refresh', () => { expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument(); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); await act(async () => view.rerender()); - expect(screen.getByLabelText('向智能体提问')).toBe(input); - expect(input).toHaveValue('接着刚才说'); + expect(screen.getByLabelText('向智能体提问')).toHaveValue(''); + expect(screen.queryByText(history.requests[0].response)).toBeNull(); + await act(async () => view.rerender()); + const restoredInput = screen.getByLabelText('向智能体提问'); + expect(restoredInput).toHaveValue('接着刚才说'); api.sendConversation.mockResolvedValue(history); await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); - expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'other' })); - fireEvent.change(input, { target: { value: '停用时保留的问题' } }); + expect(api.sendConversation).toHaveBeenCalledWith('project', original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'project' })); + fireEvent.change(restoredInput, { target: { value: '停用时保留的问题' } }); api.catalog.mockResolvedValue({ items: [added] }); await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }))); expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true'); - expect(input).toHaveValue('停用时保留的问题'); + expect(restoredInput).toHaveValue('停用时保留的问题'); expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled(); expect(within(screen.getByTestId('teacher-chat-panel')).getByText(history.requests[0].response)).toBeVisible(); expect(api.create).not.toHaveBeenCalled(); @@ -220,23 +224,23 @@ describe('teacher presence and periodic guidance', () => { it('restores the last selected disabled agent as read-only history after a restart', async () => { const saved = { ...checkInTopic(), definition: {...definition, config_id: 'disabled', name: '已停用的老师'}, conversation: {agentId: 'disabled', segmentTurns: 1, discussions: {}, seenThrough: 'check-1'} }; - localStorage.setItem('makelore-teacher-presence:kid:selected-agent', JSON.stringify('disabled')); + localStorage.setItem('makelore-teacher-presence:kid:project:selected-agent', JSON.stringify('disabled')); api.conversation.mockResolvedValue({topic: saved, before: null}); api.seenConversation.mockResolvedValue(saved); await mount(); - expect(api.conversation).toHaveBeenCalledWith('disabled'); + expect(api.conversation).toHaveBeenCalledWith('project', 'disabled'); await act(async () => fireEvent.click(screen.getByRole('button', {name: '智能体', exact: true}))); expect(screen.getByRole('complementary', {name: '已停用的老师咨询'})).toBeVisible(); expect(screen.getByRole('button', {name: '提问', exact: true})).toBeDisabled(); expect(api.sendConversation).not.toHaveBeenCalled(); }); - it('tracks unread replies per agent without creating invitation bubbles or losing the selected chat on a project switch', async () => { + it('tracks unread replies and selected agents separately for each project', async () => { const agents = ['a', 'b'].map(id => ({teacher_id: id, version: 1, is_default: id === 'a', definition: {...definition, config_id: id, name: id}})); const chats = agents.map(agent => ({...checkInTopic(), id: agent.teacher_id + '-chat', definition: agent.definition, conversation: {agentId: agent.teacher_id, segmentTurns: 1, discussions: {}}})); api.catalog.mockResolvedValue({items: agents}); - api.conversation.mockImplementation(async id => ({topic: chats.find(chat => chat.definition.config_id === id), before: null})); - api.seenConversation.mockImplementation(async (id, requestId) => { + api.conversation.mockImplementation(async (project, id) => ({topic: project === 'project' ? chats.find(chat => chat.definition.config_id === id) : null, before: null})); + api.seenConversation.mockImplementation(async (_project, id, requestId) => { const chat = chats.find(chat => chat.definition.config_id === id)!; return {...chat, revision: 3, conversation: {...chat.conversation, seenThrough: requestId}}; }); @@ -244,9 +248,12 @@ describe('teacher presence and periodic guidance', () => { expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(2); expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); await act(async () => fireEvent.click(screen.getByRole('button', {name: '与b聊天'}))); - expect(api.seenConversation).toHaveBeenCalledWith('b', 'check-1'); + expect(api.seenConversation).toHaveBeenCalledWith('project', 'b', 'check-1'); expect(screen.getByRole('button', {name: '与b聊天'}).querySelector('.teacher-companion-unread')).toBeNull(); await act(async () => view.rerender()); + expect(screen.getByRole('button', {name: '与a聊天'})).toHaveAttribute('aria-pressed', 'true'); + expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(0); + await act(async () => view.rerender()); expect(screen.getByRole('button', {name: '与b聊天'})).toHaveAttribute('aria-pressed', 'true'); expect(api.create).not.toHaveBeenCalled(); }); @@ -372,7 +379,7 @@ describe('teacher presence and periodic guidance', () => { expect(api.checkIn).not.toHaveBeenCalled(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); expect(api.create).not.toHaveBeenCalled(); - expect(api.sendConversation).toHaveBeenCalledWith('experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'})); + expect(api.sendConversation).toHaveBeenCalledWith('project', 'experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'})); }); it('restores each selected agent conversation and uses its stable identity when sending', async () => { @@ -385,19 +392,19 @@ describe('teacher presence and periodic guidance', () => { { id: oldB.id, teacherId: b.teacher_id, title: '体验历史' }, { id: oldA.id, teacherId: a.teacher_id, title: '方法历史' }, ], lastSelectedTopicId: oldB.id }); - api.conversation.mockImplementation(async id => ({topic: id === 'method' ? oldA : oldB, before: null})); + api.conversation.mockImplementation(async (_project, id) => ({topic: id === 'method' ? oldA : oldB, before: null})); api.sendConversation.mockResolvedValue({ ...oldA, revision: 3 }); await mount(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); - expect(api.conversation).toHaveBeenCalledWith('method'); + expect(api.conversation).toHaveBeenCalledWith('project', 'method'); expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible(); fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } }); await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); - expect(api.sendConversation.mock.calls[0][0]).toBe('method'); + expect(api.sendConversation.mock.calls[0][1]).toBe('method'); expect(api.create).not.toHaveBeenCalled(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible(); - expect(api.conversation).toHaveBeenCalledWith('experience'); + expect(api.conversation).toHaveBeenCalledWith('project', 'experience'); }); it('ignores a late initial history read after the student selects a different agent', async () => { @@ -408,13 +415,13 @@ describe('teacher presence and periodic guidance', () => { api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id }); let resolve!: (view: {topic: TeacherTopic; before: null}) => void; const pending = new Promise<{topic: TeacherTopic; before: null}>((done) => { resolve = done; }); - api.conversation.mockImplementation(id => id === 'a' ? pending : Promise.resolve({topic: null, before: null})); + api.conversation.mockImplementation((_project, id) => id === 'a' ? pending : Promise.resolve({topic: null, before: null})); await mount(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); await act(async () => resolve({topic: old, before: null})); expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible(); expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true'); - expect(api.conversation).toHaveBeenCalledWith('a'); + expect(api.conversation).toHaveBeenCalledWith('project', 'a'); expect(api.send).not.toHaveBeenCalled(); }); @@ -441,7 +448,7 @@ describe('teacher presence and periodic guidance', () => { expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); }); - it('shows the published greeting immediately without a model request and remembers dismissal across projects', async () => { + it('shows the published greeting without a model request and remembers dismissal in its project', async () => { api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '有什么编程问题想一起讨论?' } }); const view = await mount(); expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome'); @@ -453,6 +460,8 @@ describe('teacher presence and periodic guidance', () => { const next = await mount(); expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); await act(async () => next.rerender()); + expect(screen.getByTestId('teacher-invitation')).toBeVisible(); + await act(async () => next.rerender()); expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); }); diff --git a/tests/unit/teacher-conversation-panel.test.tsx b/tests/unit/teacher-conversation-panel.test.tsx index 0cf82b74..3c46ec8e 100644 --- a/tests/unit/teacher-conversation-panel.test.tsx +++ b/tests/unit/teacher-conversation-panel.test.tsx @@ -7,6 +7,7 @@ const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), conversation: list: vi.fn(), read: vi.fn(), legacyHistory: vi.fn(), legacyRead: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), updateDiscussion: vi.fn(), cancel: vi.fn(), seenConversation: vi.fn() })); const identity = vi.hoisted(() => ({ id: 0 })); vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, + teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations', teacherTopicsPath: (project: string) => '/legacy/' + project })); vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid-' + identity.id } }) })); const definition: TeacherDefinition = { schema_version: 1, config_id: 'agent-a', teacher_id: 'teacher', @@ -29,11 +30,11 @@ beforeEach(() => { vi.resetAllMocks(); identity.id++; localStorage.clear(); stream = new EventTarget(); api.config.mockResolvedValue({ enabled: true, definition }); api.catalog.mockResolvedValue({ items: [agent] }); - api.conversation.mockResolvedValue({ topic: topic(), before: null }); + api.conversation.mockImplementation(async projectId => ({ topic: projectId === 'project-a' ? topic() : null, before: null })); api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); api.events.mockResolvedValue(Object.assign(stream, { close: vi.fn() })); - api.seenConversation.mockImplementation(async (_agent, requestId) => ({ ...topic(), conversation: { ...topic().conversation!, seenThrough: requestId } })); - api.sendConversation.mockImplementation(async (_agent, input) => ({ ...topic(), revision: 2, + api.seenConversation.mockImplementation(async (projectId, _agent, requestId) => ({ ...topic(), projectId, conversation: { ...topic().conversation!, seenThrough: requestId } })); + api.sendConversation.mockImplementation(async (projectId, _agent, input) => ({ ...topic(), projectId, revision: 2, requests: [...topic().requests, { ...request(input.requestId, input.text), projectId: input.projectId, sourceConversationId: input.sourceConversationId }] })); }); afterEach(cleanup); @@ -41,23 +42,53 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source async function mount() { let view!: ReturnType; await act(async () => { view = render(); }); return view; } describe('continuous agent panel', () => { - it('opens one chat without topic controls or creating a topic, and preserves it across project switches', async () => { + it('ignores a previous project fetch and external snapshot after a project switch', async () => { + let resolve!: (value: { topic: TeacherTopic; before: null }) => void; + const delayed = new Promise<{ topic: TeacherTopic; before: null }>(done => { resolve = done; }); + api.conversation.mockImplementation(project => project === 'project-a' ? delayed : Promise.resolve({ topic: null, before: null })); + const view = await mount(); + await act(async () => view.rerender()); + await act(async () => resolve({ topic: topic(), before: null })); + expect(screen.queryByText('旧的问题')).toBeNull(); + expect(api.seenConversation).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + it('adopts an old global draft only in its recorded project and preserves the original', async () => { + const key = `makelore-consultation-draft:kid-${identity.id}:agent:agent-a`; + const original = JSON.stringify({ text: '旧全局草稿', projectId: 'project-a', sourceId: 'source-a' }); + localStorage.setItem(key, original); + const olderProjectKey = `makelore-consultation-draft:kid-${identity.id}:project-a:agent:agent-a`; + localStorage.setItem(olderProjectKey, JSON.stringify({ text: '升级前更旧的草稿' })); + const view = await mount(); + expect(screen.getByRole('textbox')).toHaveValue('旧全局草稿'); + await act(async () => view.rerender()); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(localStorage.getItem(key)).toBe(original); + expect(localStorage.getItem(olderProjectKey)).toContain('升级前更旧的草稿'); + await act(async () => view.rerender()); + expect(screen.getByRole('textbox')).toHaveValue('旧全局草稿'); + }); + it('keeps one chat per project and restores its own history and draft when switching back', async () => { const view = await mount(); expect(screen.queryByRole('button', { name: '智能体新话题' })).toBeNull(); expect(screen.queryByRole('combobox')).toBeNull(); expect(api.create).not.toHaveBeenCalled(); expect(screen.getByText('旧的问题')).toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: '跨项目草稿' } }); - view.rerender(); - expect(screen.getByRole('textbox')).toHaveValue('跨项目草稿'); - expect(screen.getByText('旧的问题')).toBeInTheDocument(); - expect(screen.getByText('本轮项目:游戏 · 当前操作对话')).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '天气项目草稿' } }); + await act(async () => view.rerender()); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(screen.queryByText('旧的问题')).toBeNull(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '游戏问题' } }); fireEvent.click(screen.getByRole('button', { name: '提问' })); - await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('agent-a', expect.objectContaining({ - projectId: 'project-b', sourceConversationId: 'source-b', text: '跨项目草稿' }))); + await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('project-b', 'agent-a', expect.objectContaining({ + projectId: 'project-b', sourceConversationId: 'source-b', text: '游戏问题' }))); + await act(async () => view.rerender()); + expect(screen.getByRole('textbox')).toHaveValue('天气项目草稿'); + expect(screen.getByText('旧的问题')).toBeInTheDocument(); + expect(screen.queryByText('游戏问题')).toBeNull(); }); it('merges older pages and streamed current turns without replacing earlier messages', async () => { - api.conversation.mockImplementation(async (_id, before) => ({ topic: before ? topic([request('0', '更早的问题')]) : topic(), before: before ? null : '1' })); + api.conversation.mockImplementation(async (_project, _id, before) => ({ topic: before ? topic([request('0', '更早的问题')]) : topic(), before: before ? null : '1' })); await mount(); fireEvent.click(screen.getByText('查看更早消息')); await screen.findByText('更早的问题'); @@ -68,12 +99,12 @@ describe('continuous agent panel', () => { expect(screen.getByText('旧的问题')).toBeInTheDocument(); expect(screen.getByText('新的问题')).toBeInTheDocument(); }); - it('rejects stale references and prevents carrying an old project reply into the new Pi draft', async () => { + it('rejects stale references and prevents carrying an old Pi reply into another Pi draft', async () => { const bring = vi.fn(); let view!: ReturnType; await act(async () => { view = render(); }); fireEvent.change(screen.getByRole('textbox'), { target: { value: '看看' } }); - view.rerender(); + view.rerender(); fireEvent.click(screen.getByRole('button', { name: '提问' })); expect(api.sendConversation).not.toHaveBeenCalled(); expect(screen.getByRole('alert')).toHaveTextContent('引用来自另一个项目'); @@ -91,7 +122,7 @@ describe('continuous agent panel', () => { it('merges a delayed older page without overwriting a newer streamed answer', async () => { let resolve!: (value: {topic: TeacherTopic; before: null}) => void; const page = new Promise<{topic: TeacherTopic; before: null}>(done => { resolve = done; }); - api.conversation.mockImplementation((_id, before) => before ? page : Promise.resolve({ topic: topic(), before: '1' })); + api.conversation.mockImplementation((_project, _id, before) => before ? page : Promise.resolve({ topic: topic(), before: '1' })); await mount(); fireEvent.click(screen.getByText('查看更早消息')); await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', {data: JSON.stringify({ diff --git a/tests/unit/teacher-conversation.test.ts b/tests/unit/teacher-conversation.test.ts index d4ba8f9b..3b0efd39 100644 --- a/tests/unit/teacher-conversation.test.ts +++ b/tests/unit/teacher-conversation.test.ts @@ -36,13 +36,82 @@ function turn(index: number): TeacherRequest { omittedMessages: 0, status: 'completed', response: '回答 ' + index }; } describe('continuous conversation persistence', () => { + it('splits mixed global history by recorded project, preserving originals, seen positions and legacy identities', async () => { + const dir = await root(), global = path.join(dir, 'global'); + const project = { id: randomUUID(), path: path.join(dir, 'project'), name: '天气' }; + const other = randomUUID(), oldId = randomUUID(); + const legacyTurn = turn(0); + const imported = { ...legacyTurn, id: randomUUID(), projectId: project.id, + origin: { projectId: project.id, topicId: oldId, requestId: legacyTurn.id } }; + const turns = [imported, ...Array.from({ length: 110 }, (_, i) => ({ + ...turn(i + 1), projectId: i % 2 ? other : project.id, runtimeThreadId: 'mixed-cloud-thread', + })), turn(112)]; // This old turn has no proven project and must remain only in the original archive. + const metadata = { schemaVersion: 1, revision: 4, accountId: 'account', id: randomUUID(), + projectId: other, sourceConversationId: 'project', definition, version: 1, + createdAt: 'now', updatedAt: 'now', conversation: { agentId: 'agent-a', segmentTurns: 0, discussions: {}, + seenThrough: turns[42].id } }; + const manifest = { topic: metadata, turns: turns.map(({ id, createdAt }) => ({ id, createdAt, messages: [] })) }; + await atomicWriteJson(path.join(global, 'conversation.json'), manifest); + for (const request of turns) await atomicWriteJson(path.join(global, 'turns', request.id + '.json'), request); + const orphan = { ...turn(113), projectId: other, status: 'running' }; + await atomicWriteJson(path.join(global, 'turns', orphan.id + '.json'), orphan); + const original = await readFile(path.join(global, 'conversation.json'), 'utf8'); + const originalTurn = await readFile(path.join(global, 'turns', orphan.id + '.json'), 'utf8'); + const target = path.join(dir, 'a'); + const a = new TeacherConversationStore(target, 'account', 'agent-a', project.id); + await a.importGlobal(global); + const b = new TeacherConversationStore(path.join(dir, 'b'), 'account', 'agent-a', other); + await b.importGlobal(global); + expect((await a.read()).id).not.toBe((await b.read()).id); + expect((await a.history()).messages).toHaveLength(56 * 2); + expect((await b.history()).messages).toHaveLength(55 * 2); // Interrupted turns stay visible but are not model context. + expect((await a.page()).requests.every(request => request.projectId === project.id)).toBe(true); + expect((await a.read()).conversation?.seenThrough).toBe(turns[41].id); + expect((await b.read()).conversation?.seenThrough).toBe(turns[42].id); + expect((await b.findRequest(orphan.id))?.status).toBe('interrupted'); + expect((await a.findRequest(turns[1].id))?.origin?.topicId).toBe(metadata.id); + expect((await a.findRequest(turns[1].id))?.cloudRequestId).toBe(turns[1].id); + const legacy = new TeacherTopicStore(path.join(project.path, '.makelore', 'teacher-conversations', 'account', 'project')); + await legacy.save({ ...metadata, id: oldId, projectId: project.id, conversation: undefined, requests: [legacyTurn] }); + await a.importProject(project); + const resumed = new TeacherConversationStore(target, 'account', 'agent-a', project.id); + await resumed.importGlobal(global); await resumed.importProject(project); + expect((await resumed.history()).messages).toHaveLength(56 * 2); + expect(await readFile(path.join(global, 'conversation.json'), 'utf8')).toBe(original); + expect(await readFile(path.join(global, 'turns', orphan.id + '.json'), 'utf8')).toBe(originalTurn); + const foreign = new TeacherConversationStore(path.join(dir, 'foreign'), 'other-account', 'agent-a', project.id); + await foreign.importGlobal(global); + expect(await foreign.exists()).toBe(false); + }); + it('resumes a partial global split after an index write failure without duplicate messages', async () => { + const dir = await root(), old = path.join(dir, 'global'), dest = path.join(dir, 'project'); + const request = { ...turn(1), projectId: 'project-a' }; + await atomicWriteJson(path.join(old, 'conversation.json'), { topic: { + accountId: 'account', id: randomUUID(), definition, version: 1, + conversation: { agentId: 'agent-a', discussions: {} }, + }, turns: [{ id: request.id, createdAt: request.createdAt, messages: [] }] }); + await atomicWriteJson(path.join(old, 'turns', request.id + '.json'), request); + const store = new TeacherConversationStore(dest, 'account', 'agent-a', 'project-a'); + await store.ensure(definition, 1); + const write = atomic.atomicWriteJson; + let fail = true; + vi.spyOn(atomic, 'atomicWriteJson').mockImplementation(async (file, value) => { + if (fail && file === path.join(dest, 'conversation.json')) { fail = false; throw new Error('disk unavailable'); } + return write(file, value); + }); + await expect(store.importGlobal(old)).rejects.toThrow('disk unavailable'); + await store.importGlobal(old); + expect((await store.read()).requests.map(turn => turn.id)).toEqual([request.id]); + await new TeacherConversationStore(dest, 'account', 'agent-a', 'project-a').importGlobal(old); + expect((await store.history()).messages).toHaveLength(2); + }); it('pages thousands of turns, restores the latest page and preserves repeated questions', async () => { const dir = await root(); - const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const store = new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a'); const topic = await store.ensure(definition, 1); topic.requests = Array.from({ length: 1001 }, (_, i) => turn(i)); await store.save(topic); - const resumed = new TeacherConversationStore(dir, 'account', 'agent-a'); + const resumed = new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a'); expect((await resumed.read()).requests).toHaveLength(50); const latest = await resumed.page(); expect(latest.requests.at(-1)?.response).toBe('回答 1000'); @@ -59,15 +128,15 @@ describe('continuous conversation persistence', () => { expect((await tools.executeResult('read_conversation', JSON.stringify({message_id: `teacher:${topic.requests.at(-1)!.id}:assistant`}), signal)).status).toBe('error'); await resumed.markSeen(latest.requests.at(-1)!.id); await resumed.markSeen(older.requests[0].id); - expect((await new TeacherConversationStore(dir, 'account', 'agent-a').read()).conversation?.seenThrough).toBe(latest.requests.at(-1)!.id); + expect((await new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a').read()).conversation?.seenThrough).toBe(latest.requests.at(-1)!.id); }); it('recovers a turn written before its index and marks only unfinished work interrupted', async () => { const dir = await root(); - const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const store = new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a'); await store.ensure(definition, 1); const pending = { ...turn(1), status: 'running' as const }; await atomicWriteJson(path.join(dir, 'turns', pending.id + '.json'), pending); - const resumed = new TeacherConversationStore(dir, 'account', 'agent-a'); + const resumed = new TeacherConversationStore(dir, 'account', 'agent-a', 'project-a'); expect((await resumed.read()).requests[0]).toMatchObject({ id: pending.id, status: 'interrupted' }); expect((await resumed.page()).requests).toHaveLength(1); }); @@ -80,14 +149,14 @@ describe('continuous conversation persistence', () => { await oldStore.save(old); const file = path.join(oldStore.directory, old.id + '.json'), before = await readFile(file, 'utf8'); const target = path.join(dir, 'new'); - const store = new TeacherConversationStore(target, 'account', 'agent-a'); + const store = new TeacherConversationStore(target, 'account', 'agent-a', project.id); await store.importProject(project); - const resumed = new TeacherConversationStore(target, 'account', 'agent-a'); + const resumed = new TeacherConversationStore(target, 'account', 'agent-a', project.id); await resumed.importProject(project); expect((await resumed.read()).requests).toHaveLength(2); expect((await resumed.read()).requests[0].origin?.topicId).toBe(old.id); expect(await readFile(file, 'utf8')).toBe(before); - const other = new TeacherConversationStore(path.join(dir, 'other'), 'another-account', 'agent-a'); + const other = new TeacherConversationStore(path.join(dir, 'other'), 'another-account', 'agent-a', project.id); await other.importProject(project); expect(await other.exists()).toBe(false); }); @@ -97,7 +166,7 @@ describe('continuous conversation persistence', () => { const old: TeacherTopic = { schemaVersion: 1, revision: 1, accountId: 'account', id: randomUUID(), projectId: project.id, sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: [turn(1)] }; await oldStore.save(old); - const store = new TeacherConversationStore(path.join(dir, 'chat'), 'account', 'agent-a'); + const store = new TeacherConversationStore(path.join(dir, 'chat'), 'account', 'agent-a', project.id); await store.ensure(definition, 1); const write = atomic.atomicWriteJson; let fail = true; @@ -123,7 +192,9 @@ it('serves paged account chats and compact SSE, preserves legacy source records {codingProducts: {teacher: f.service}} as HostApiContext); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('no address'); - const origin = 'http://127.0.0.1:' + address.port, base = origin + '/api/coding/agent-conversations/agent-a'; + const origin = 'http://127.0.0.1:' + address.port; + const base = origin + '/api/coding/projects/' + f.a.project.id + '/agent-conversations/agent-a'; + const otherBase = origin + '/api/coding/projects/' + f.b.project.id + '/agent-conversations/agent-a'; const post = (url: string, input: unknown) => fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(input)}); try { const page = await (await fetch(base)).json(); @@ -135,15 +206,20 @@ it('serves paged account chats and compact SSE, preserves legacy source records expect((await post(archive, {})).status).toBe(405); expect(f.run).not.toHaveBeenCalled(); const input = {projectId: f.b.project.id, sourceConversationId: f.bSource.id, requestId: randomUUID(), text: '继续'}; - expect((await post(base + '/messages', input)).status).toBe(202); - expect((await post(base + '/messages', input)).status).toBe(202); - await f.settled(); expect(f.run).toHaveBeenCalledTimes(1); - const reader = (await fetch(base + '/events')).body!.getReader(); + expect((await (await fetch(otherBase)).json()).topic).toBeNull(); + expect((await post(base + '/messages', input)).status).toBe(422); + expect((await post(otherBase + '/messages', input)).status).toBe(202); + expect((await post(otherBase + '/messages', input)).status).toBe(202); + await f.settled(f.b); expect(f.run).toHaveBeenCalledTimes(1); + const reader = (await fetch(otherBase + '/events')).body!.getReader(); const chunk = new TextDecoder().decode((await reader.read()).value); const snapshot = JSON.parse(chunk.split('data: ')[1].split('\n')[0]); expect(snapshot.requests).toHaveLength(1); expect(snapshot.requests[0].id).toBe(input.requestId); await reader.cancel(); - expect((await (await post(base + '/seen', {requestId: input.requestId})).json()).conversation.seenThrough).toBe(input.requestId); + expect((await (await post(otherBase + '/seen', {requestId: input.requestId})).json()).conversation.seenThrough).toBe(input.requestId); + expect((await (await post(base + '/seen', {requestId: input.requestId})).json()).conversation.seenThrough).toBeUndefined(); + expect((await (await fetch(base)).json()).topic.requests).toHaveLength(50); + expect((await post(otherBase + '/discussion', {projectId: f.a.project.id, action: 'archive'})).status).toBe(422); expect((await fetch(base + '?before=missing')).status).toBe(400); } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } }); @@ -160,7 +236,7 @@ async function fixture() { let wait = false, version = 1, enabled = true, accountId = 'account'; const run = vi.fn(async (messages, _signal: AbortSignal, onText: (text: string) => void) => { onText('已查看'); - if (wait) await new Promise(resolve => { release = resolve; }); + if (wait) { wait = false; await new Promise(resolve => { release = resolve; }); } return { inputTokens: 10, outputTokens: 5 }; }); const prepareCloud = vi.fn((_account, _topic, _id, _access) => ({ inputLimit: 8000, run })); @@ -175,17 +251,47 @@ async function fixture() { const send = (project = a, session = aSource, requestId = randomUUID()) => service.sendConversation('agent-a', { projectId: project.project.id, sourceConversationId: session.id, requestId, text: '帮我看看', }); - const settled = async () => { - await vi.waitFor(async () => expect((await service.conversation('agent-a')).topic?.requests.at(-1)?.status).toBe('completed')); - return (await service.conversation('agent-a')).topic!; + const settled = async (project = a) => { + await vi.waitFor(async () => expect((await service.conversation(project.project.id, 'agent-a')).topic?.requests.at(-1)?.status).toBe('completed')); + return (await service.conversation(project.project.id, 'agent-a')).topic!; }; - return { service, a, b, aSource, aOther, bSource, prepareCloud, run, send, settled, + return { dir, service, a, b, aSource, aOther, bSource, prepareCloud, run, send, settled, wait: () => { wait = true; }, release: () => { wait = false; release?.(); }, version: (v: number) => { version = v; }, enabled: (v: boolean) => { enabled = v; }, account: (id: string) => { accountId = id; } }; } -describe('one account-agent conversation', () => { - it('keeps one visible chat across projects and versions while freezing each execution scope', async () => { +describe('one account-project-agent conversation', () => { + it('starts a clean cloud segment after splitting mixed history and supplies only the selected project messages', async () => { + const f = await fixture(), global = path.join(f.dir, 'agent-conversations', 'account', 'agent-a'); + const turns = [ + { ...turn(1), projectId: f.a.project.id, sourceConversationId: f.aSource.id, runtimeThreadId: 'old-mixed-thread', teacherVersion: 1, text: '天气专属历史' }, + { ...turn(2), projectId: f.b.project.id, sourceConversationId: f.bSource.id, runtimeThreadId: 'old-mixed-thread', teacherVersion: 1, text: '游戏专属历史' }, + ]; + await atomicWriteJson(path.join(global, 'conversation.json'), { topic: { + id: randomUUID(), accountId: 'account', definition, version: 1, projectId: f.b.project.id, + conversation: { agentId: 'agent-a', runtimeThreadId: 'old-mixed-thread', discussions: {} }, + }, turns: turns.map(({ id, createdAt }) => ({ id, createdAt, messages: [] })) }); + for (const turn of turns) await atomicWriteJson(path.join(global, 'turns', turn.id + '.json'), turn); + await f.send(); const current = await f.settled(); + expect(current.requests).toHaveLength(2); + expect(current.requests.at(-1)?.runtimeThreadId).not.toBe('old-mixed-thread'); + const messages = JSON.stringify(f.run.mock.calls[0][0]); + expect(messages).toContain('天气专属历史'); + expect(messages).not.toContain('游戏专属历史'); + }); + it('isolates the same agent between projects and restores each project chat', async () => { + const f = await fixture(); + const a = await f.send(); await f.settled(); + const b = await f.send(f.b, f.bSource); await f.settled(f.b); + expect(b.id).not.toBe(a.id); + expect(b.requests).toHaveLength(1); + expect(JSON.stringify(f.run.mock.calls[1][0])).not.toContain('weather'); + const back = await f.send(); await f.settled(); + expect(back.id).toBe(a.id); + expect(back.requests).toHaveLength(2); + expect(back.requests.every(request => request.projectId === f.a.project.id)).toBe(true); + }); + it('keeps one visible chat per project across versions while freezing each execution scope', async () => { const f = await fixture(); await f.send(); const first = await f.settled(); await f.send(); const same = await f.settled(); @@ -193,18 +299,18 @@ describe('one account-agent conversation', () => { expect(same.requests[1].runtimeThreadId).toBe(first.requests[0].runtimeThreadId); // The same cloud checkpoint already owns the previous exchange. expect(f.run.mock.calls[1][0].filter((m: { content: string }) => m.content.includes('已查看'))).toHaveLength(0); - await f.send(f.b, f.bSource); const second = await f.settled(); - expect(second.id).toBe(first.id); - expect(second.requests[2].runtimeThreadId).not.toBe(first.requests[0].runtimeThreadId); + await f.send(f.b, f.bSource); const second = await f.settled(f.b); + expect(second.id).not.toBe(first.id); + expect(second.requests[0].runtimeThreadId).not.toBe(first.requests[0].runtimeThreadId); expect(f.prepareCloud.mock.calls[2][3].projectPath).toBe(f.b.project.path); - expect(JSON.stringify(f.run.mock.calls[2][0])).toContain('weather'); - f.version(2); await f.send(f.b, f.bSource); const updated = await f.settled(); - expect(updated.id).toBe(first.id); - expect(updated.requests.map(request => request.teacherVersion)).toEqual([1, 1, 1, 2]); - expect(updated.requests[3].runtimeThreadId).not.toBe(updated.requests[2].runtimeThreadId); + expect(JSON.stringify(f.run.mock.calls[2][0])).not.toContain('weather'); + f.version(2); await f.send(f.b, f.bSource); const updated = await f.settled(f.b); + expect(updated.id).toBe(second.id); + expect(updated.requests.map(request => request.teacherVersion)).toEqual([1, 2]); + expect(updated.requests[1].runtimeThreadId).not.toBe(updated.requests[0].runtimeThreadId); f.enabled(false); await expect(f.send()).rejects.toMatchObject({ code: 'teacher_disabled' }); - expect((await f.service.conversation('agent-a')).topic?.requests).toHaveLength(4); + expect((await f.service.conversation(f.a.project.id, 'agent-a')).topic?.requests).toHaveLength(2); }); it('deduplicates concurrent submissions, rejects a second active turn and rotates on Pi source change', async () => { const f = await fixture(); f.wait(); @@ -212,13 +318,19 @@ describe('one account-agent conversation', () => { const [a, b] = await Promise.all([f.send(f.a, f.aSource, requestId), f.send(f.a, f.aSource, requestId)]); expect(a.id).toBe(b.id); expect(f.run).toHaveBeenCalledTimes(1); - await expect(f.send(f.b, f.bSource)).rejects.toMatchObject({ code: 'teacher_topic_busy' }); + await expect(f.send()).rejects.toMatchObject({ code: 'teacher_topic_busy' }); + const other = await f.send(f.b, f.bSource); await f.settled(f.b); + expect((await f.service.conversation(f.a.project.id, 'agent-a')).topic?.requests[0].status).toBe('running'); + const otherScope = { projectId: f.b.project.id, sourceId: 'project', agentId: 'agent-a' }; + await f.service.cancel(otherScope, other.id, requestId); + expect(f.run.mock.calls[0][1].aborted).toBe(false); + await expect(f.service.read(otherScope, a.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' }); expect(f.prepareCloud.mock.calls[0][3].projectPath).toBe(f.a.project.path); f.release(); await f.settled(); await f.send(f.a, f.aOther); const next = await f.settled(); expect(next.requests[1].runtimeThreadId).not.toBe(next.requests[0].runtimeThreadId); f.account('other'); - expect((await f.service.conversation('agent-a')).topic).toBe(null); + expect((await f.service.conversation(f.a.project.id, 'agent-a')).topic).toBe(null); await f.send(); expect((await f.settled()).id).not.toBe(a.id); }); });