From 00319a880b22dfcd7380cf8bcbebd674eb60e2d8 Mon Sep 17 00:00:00 2001 From: hy Date: Mon, 3 Aug 2026 10:57:37 +0800 Subject: [PATCH] Refactor path utilities and update model path usage across services - Replaced direct usage of MODELS_PATH with getModelsPath() in ModelService and VectorModelDownloadService. - Updated LanceDbCore and SqliteCore to use getLanceDbPath() and getSqliteDbPath() respectively. - Refactored WorkspaceKnowledgeCache to utilize getUserDataPath(). - Introduced new utility functions in paths.ts for better path management. - Updated various tests to reflect changes in path handling. - Enhanced file change tracking in terminal tests to ensure isolation between runs. - Added unit tests for file change run scope functionality. --- doc/test-plan/ai-test.md | 16 +- doc/test-plan/workbench-test.md | 9 + src/main/index.ts | 4 +- src/main/services/model/ModelService.ts | 6 +- .../model/VectorModelDownloadService.ts | 6 +- src/main/services/storage/core/LanceDbCore.ts | 7 +- .../services/storage/sqlite/SqliteCore.ts | 7 +- .../workspace/WorkspaceKnowledgeCache.ts | 4 +- src/main/utils/paths.ts | 23 ++- .../workbench/WorkspaceFileExplorer.tsx | 2 +- .../components/workbench/applyTargetUtils.ts | 8 +- src/renderer/src/pages/ChatPage.tsx | 44 +++-- src/shared/aiFileChangeScope.ts | 41 +++++ ...inal-inline-edit-artifact-contract.test.ts | 89 +++++++++ ...rminal-persistent-session-contract.test.ts | 74 +++++++- .../unit/ai/file-change-run-scope.test.ts | 60 ++++++ .../unit/ai/model-path-candidates.test.ts | 25 +++ .../chat-run-service-contract.test.ts | 33 +++- ...ctor-download-single-task-contract.test.ts | 4 +- ...r-download-startup-resume-contract.test.ts | 8 +- .../apply/file-change-run-scope.e2e.test.ts | 172 ++++++++++++++++++ tests/harness/backend/realAiDepsFactory.ts | 4 +- 22 files changed, 582 insertions(+), 64 deletions(-) create mode 100644 src/shared/aiFileChangeScope.ts create mode 100644 tests/case/unit/ai/file-change-run-scope.test.ts create mode 100644 tests/case/workbench/result/apply/file-change-run-scope.e2e.test.ts diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 1757a083..407f29a7 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -102,6 +102,7 @@ AI unit / 辅助测试目录: ### 3.4 覆盖归属与去重规则 - `ai/tool` 负责生产工具 runtime 的完整输入输出、读写链、错误边界、原子性、修改记录与回退契约。 +- `ChatRunService` 合同测试验证 run 结束不会扫描并归属未受控的工作区变化;只有受控文件工具与 terminal 命令窗口能创建 run 级修改记录。 - `ai/human/mock-llm` 负责真实 human graph 下的工具学习、工具选择、串联执行、暂停恢复和结果交付;只允许固定 LLM 输出,不允许替换工具执行结果。 - `ai/human/live` 负责真实模型在自然语言输入下能否自主选择并正确使用能力;用户输入不得包含内部工具名或固定调用顺序。 - Workbench E2E 负责用户可见的附件、预览、Diff、跨 Sheet 交互与回退闭环,不重复验证工具内部算法。 @@ -790,6 +791,15 @@ AI unit / 辅助测试目录: - Oracle:命令真实生效并经过审批;terminal 工具结果不包含 `artifacts`;命令完成后产生带当前 Run ID 的修改记录和 `file_artifact`。 - Not:不根据命令文本推断文件路径;差异只来自命令前后工作区快照。 +##### `terminal-tracking-start-failure-cleanup` + +- 测试类型:`backend` +- What:终端命令已建立追踪基线但 PTY 启动失败时,追踪上下文会被立即丢弃。 +- Why:启动失败不应留下无法完成的快照、修改记录或文件 artifact。 +- How:真实构建 terminal 工具,替换终端会话启动为明确失败的实现,并记录开始、完成和丢弃追踪调用。 +- Oracle:恰好开始并丢弃同一个命令追踪 ID;不调用完成追踪,不产生修改记录或 `file_artifact`。 +- Not:不验证 PTY 本身的启动错误归因;该能力由终端会话管理测试覆盖。 + ##### `terminal-inline-interpreter-execution` - 测试类型:`backend` @@ -802,10 +812,10 @@ AI unit / 辅助测试目录: ##### `terminal-persistent-session-contract` - 测试类型:`backend` -- What:真实 PTY 持久终端保留当前路径、长命令让出控制权、增量读取和停止。 +- What:真实 PTY 持久终端保留当前路径、长命令让出控制权、增量读取、停止和命令级文件追踪。 - Why:后台服务和长任务不能继续依赖单次 shell 超时或系统孤儿进程。 -- How:在同一 terminalId 中真实执行 cd、pwd 和持续输出命令,同时新开第二终端;短命令若先返回 running,则通过 terminal_read 等到 idle 后再复用;长命令使用 terminal_read 读取输出并用 terminal_stop 停止。 -- Oracle:同一终端保留路径,新终端从 workspace 根目录启动;长命令返回 command_still_running 且进程仍在;读取能取得真实增量输出;正常停止后终端回到 idle,强制停止后终端进入 closed,且均无子进程残留。 +- How:在同一 terminalId 中真实执行 cd、pwd 和持续输出命令,同时新开第二终端;从子目录分别执行“长命令正常完成”“写入后停止”和“写入后非零退出”,通过 terminal_read 等到 idle 或调用 terminal_stop。 +- Oracle:同一终端保留路径,新终端从 workspace 根目录启动;长命令返回 command_still_running 且进程仍在;读取能取得真实增量输出;正常停止后终端回到 idle,强制停止后终端进入 closed,且均无子进程残留。三条终端写命令均以工作区根目录为扫描范围,生成当前 run 的 `terminal_tool` 修改记录和 `file_artifact`。 - Not:不验证 Workbench 浏览器联动,该能力由真实 e2e 覆盖。 #### web-browsing 类别 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index fd24812a..16895eb9 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -400,6 +400,15 @@ tests/case/workbench/ - Oracle:AI 写入后普通预览显示最新内容且不误报外部修改;普通预览与审核模式共用一个文件 Tab;Run 终态后才展示修改入口;审核模式展示差异数量、修改状态和回退,预览模式全部隐藏;已应用记录打开外部应用后显示重新打开提醒;文件夹入口打开的文件列表从工具栏底部贴右悬浮,不改变预览宽度,可通过外部点击、Escape 或选择文件关闭;当前已经是最新记录且处于预览模式时,再次点击文件总分类仍会重置到最新审核模式;历史批次、结构化 Diff、不可变展开和回退刷新整体对齐 `WB-PANEL-003`、`WB-APPLY-001`、`WB-APPLY-002`、`WB-APPLY-003`;确认回退后 bytes 与原始输入一致且有效改动标记消失。 - Not:不验证 unified diff 字符串;UI 只消费结构化 diff artifact。 +#### `file-change-run-scope` + +- 测试类型:`e2e / live` +- What:两个会话各完成一次真实终端写入后,每条 assistant 修改入口与右侧“本次运行改动”只展示该 `{ sessionId, runId }` 的文件。 +- Why:文件改动归属已经从会话级改为 run 级,后台或历史会话完成时不能把其他 run 的记录混入当前审核列表。 +- How:在首个会话让真实 AI 只通过 `terminal_run` 创建第一个文件;新建并绑定同一工作区的第二个会话,再让真实 AI 创建另一个文件;分别打开两条修改入口,再回到首个会话复查右侧变更列表。 +- Oracle:两条 run 和两个会话 ID 均不同;每条 assistant 卡片只含自身文件;右侧“本次运行改动 1”只含当前 run 文件,切回历史会话后仍不出现另一 run 的文件,对齐 `WB-PANEL-003`、`WB-APPLY-003`。 +- Not:不要求两个终端命令实际时间重叠;重叠命令的允许重复归属由后端并发单测覆盖。 + #### `ppt-create-diff-and-revert` - 测试类型:`e2e / live` diff --git a/src/main/index.ts b/src/main/index.ts index af4b7b3e..c9656aa3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import { CdpEndpointService } from './services/browser/CdpEndpointService' import { createWebCrawlerService } from './services/webCrawler/createWebCrawlerService' import { AppUpdateService } from './services/update/AppUpdateService' import { APP_ID } from '../shared/constants' -import { MODELS_PATH } from './utils/paths' +import { getModelsPath } from './utils/paths' import { terminalSessionManager } from './services/ai/human/tools/terminal/TerminalSessionManager' import { AppShutdownCoordinator } from './services/AppShutdownCoordinator' import { SystemNotificationService } from './services/SystemNotificationService' @@ -201,7 +201,7 @@ if (!singleInstanceLock) { const ingestionService = IngestionService.getInstance() const embeddingRuntimeClient = new EmbeddingRuntimeClient({ entryPath: fileURLToPath(new URL('./embeddingRuntimeProcess.js', import.meta.url)), - modelsPath: MODELS_PATH, + modelsPath: getModelsPath(), modelName: DEFAULT_EMBEDDING_MODEL_NAME }) const embeddingService = new EmbeddingService({ diff --git a/src/main/services/model/ModelService.ts b/src/main/services/model/ModelService.ts index e6f89c76..e85fab4a 100644 --- a/src/main/services/model/ModelService.ts +++ b/src/main/services/model/ModelService.ts @@ -3,7 +3,7 @@ import path from 'path' import { Readable, Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { ReadableStream as NodeReadableStream } from 'node:stream/web' -import { listModelPathCandidates, MODELS_PATH } from '../../utils/paths' +import { getModelsPath, listModelPathCandidates } from '../../utils/paths' const HF_ENDPOINT = 'https://hf-mirror.com' const DOWNLOAD_LOCK_POLL_MS = 1000 @@ -111,11 +111,11 @@ export class ModelService { } private resolveModelPath(repoId: string): string { - return path.join(MODELS_PATH, repoId) + return path.join(getModelsPath(), repoId) } private resolveDownloadLockPath(repoId: string): string { - return path.join(MODELS_PATH, `${repoId.replaceAll('/', '__')}.download.lock`) + return path.join(getModelsPath(), `${repoId.replaceAll('/', '__')}.download.lock`) } private resolveDownloadStatePath(repoId: string): string { diff --git a/src/main/services/model/VectorModelDownloadService.ts b/src/main/services/model/VectorModelDownloadService.ts index 194199d7..3c94000c 100644 --- a/src/main/services/model/VectorModelDownloadService.ts +++ b/src/main/services/model/VectorModelDownloadService.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' import type { EmbeddingService } from '../vector/EmbeddingService' -import { MODELS_PATH } from '../../utils/paths' +import { getModelsPath } from '../../utils/paths' import type { ModelDownloadProgress } from './ModelService' import { ModelService } from './ModelService' @@ -211,7 +211,7 @@ export class VectorModelDownloadService { } private resolveSnapshotPath(): string { - return path.join(MODELS_PATH, ACTIVE_DOWNLOAD_SNAPSHOT_FILE) + return path.join(getModelsPath(), ACTIVE_DOWNLOAD_SNAPSHOT_FILE) } private readSnapshot(): ActiveVectorDownloadSnapshot | null { @@ -238,7 +238,7 @@ export class VectorModelDownloadService { private setSnapshot(snapshot: ActiveVectorDownloadSnapshot): void { this.activeSnapshot = snapshot - fs.mkdirSync(MODELS_PATH, { recursive: true }) + fs.mkdirSync(getModelsPath(), { recursive: true }) fs.writeFileSync(this.resolveSnapshotPath(), JSON.stringify(snapshot, null, 2), 'utf8') } diff --git a/src/main/services/storage/core/LanceDbCore.ts b/src/main/services/storage/core/LanceDbCore.ts index c664c2db..5d22fc61 100644 --- a/src/main/services/storage/core/LanceDbCore.ts +++ b/src/main/services/storage/core/LanceDbCore.ts @@ -1,7 +1,7 @@ import * as lancedb from '@lancedb/lancedb' import fs from 'fs' import * as arrow from 'apache-arrow' -import { LANCE_DB_PATH } from '../../../utils/paths' +import { getLanceDbPath } from '../../../utils/paths' import type { TableConfig } from '../../../types/store' export enum LanceDbCoreStatus { @@ -19,12 +19,15 @@ export type LanceTableName = (typeof LANCE_TABLES)[keyof typeof LANCE_TABLES] export class LanceDbCore { private db: lancedb.Connection | null = null + private readonly dbPath: string private status: LanceDbCoreStatus = LanceDbCoreStatus.UNINITIALIZED private tableNamesCache: Set | null = null private tableCache = new Map() private tableWriteQueues = new Map>() - public constructor(private readonly dbPath: string = LANCE_DB_PATH) {} + public constructor(dbPath?: string) { + this.dbPath = dbPath || getLanceDbPath() + } public getStatus(): LanceDbCoreStatus { return this.status diff --git a/src/main/services/storage/sqlite/SqliteCore.ts b/src/main/services/storage/sqlite/SqliteCore.ts index 4e2b0220..e86cce09 100644 --- a/src/main/services/storage/sqlite/SqliteCore.ts +++ b/src/main/services/storage/sqlite/SqliteCore.ts @@ -1,7 +1,7 @@ import Database from 'better-sqlite3' import fs from 'fs' import path from 'path' -import { SQLITE_DB_PATH } from '../../../utils/paths' +import { getSqliteDbPath } from '../../../utils/paths' import { initialSchemaSql } from './migrations/001_initial_schema' const notificationSettingsMigration = ` @@ -122,8 +122,11 @@ const resolveBetterSqliteNativeBinding = (): string => { export class SqliteCore { private db: SqliteDatabase | null = null + private readonly dbPath: string - public constructor(private readonly dbPath: string = SQLITE_DB_PATH) {} + public constructor(dbPath?: string) { + this.dbPath = dbPath || getSqliteDbPath() + } public initialize(): void { if (this.db) return diff --git a/src/main/services/workspace/WorkspaceKnowledgeCache.ts b/src/main/services/workspace/WorkspaceKnowledgeCache.ts index 4d3bf9c2..bfb8bdeb 100644 --- a/src/main/services/workspace/WorkspaceKnowledgeCache.ts +++ b/src/main/services/workspace/WorkspaceKnowledgeCache.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { SPLITTER_BIG_CHUNK_SETTING, SPLITTER_MINI_CHUNK_SIZE } from '../../utils/constant' -import { USER_DATA_PATH } from '../../utils/paths' +import { getUserDataPath } from '../../utils/paths' import type { WorkspaceKnowledgeCache, WorkspaceKnowledgeSplitSnapshot, @@ -46,7 +46,7 @@ const isWorkspaceKnowledgeCacheEnabled = (): boolean => const resolveWorkspaceKnowledgeCacheRoot = (): string => { const explicitPath = String(process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH || '').trim() if (explicitPath) return path.resolve(explicitPath) - return path.join(USER_DATA_PATH, 'workspace-knowledge-cache') + return path.join(getUserDataPath(), 'workspace-knowledge-cache') } const resolveWorkspaceKnowledgeCacheStatsPath = (cacheRoot: string): string => { diff --git a/src/main/utils/paths.ts b/src/main/utils/paths.ts index 3165b3c2..a3167245 100644 --- a/src/main/utils/paths.ts +++ b/src/main/utils/paths.ts @@ -2,8 +2,6 @@ import { app } from 'electron' import os from 'os' import path from 'path' -const E2E_USER_DATA_PATH = String(process.env.NCURATOR_USER_DATA_PATH || '').trim() -const E2E_MODELS_PATH = String(process.env.NCURATOR_MODELS_PATH || '').trim() const FALLBACK_USER_DATA_PATH = path.join(os.tmpdir(), 'ncurator-user-data') const KNOWN_USER_DATA_DIRS = ['ncurator', 'ncurator-desktop'] as const @@ -54,14 +52,17 @@ const resolveElectronUserDataPath = (): string => { return FALLBACK_USER_DATA_PATH } -export const USER_DATA_PATH = E2E_USER_DATA_PATH - ? path.resolve(E2E_USER_DATA_PATH) - : resolveElectronUserDataPath() -export const SQLITE_DB_PATH = path.join(USER_DATA_PATH, 'app.sqlite') -export const LANCE_DB_PATH = path.join(USER_DATA_PATH, 'lancedb') +export const getUserDataPath = (): string => { + const overridePath = String(process.env.NCURATOR_USER_DATA_PATH || '').trim() + return overridePath ? path.resolve(overridePath) : resolveElectronUserDataPath() +} + +export const getSqliteDbPath = (): string => path.join(getUserDataPath(), 'app.sqlite') + +export const getLanceDbPath = (): string => path.join(getUserDataPath(), 'lancedb') export const listModelPathCandidates = ( - userDataPath: string = USER_DATA_PATH, + userDataPath: string = getUserDataPath(), platform: NodeJS.Platform = process.platform, homeDir: string = os.homedir(), tmpDir: string = os.tmpdir() @@ -75,11 +76,13 @@ export const listModelPathCandidates = ( : [activeModelsPath, ...persistentModelPaths] const candidates = [ - E2E_MODELS_PATH ? path.resolve(E2E_MODELS_PATH) : '', + String(process.env.NCURATOR_MODELS_PATH || '').trim() + ? path.resolve(String(process.env.NCURATOR_MODELS_PATH).trim()) + : '', ...orderedModelPaths ].filter(Boolean) return dedupePaths(candidates) } -export const MODELS_PATH = listModelPathCandidates()[0] +export const getModelsPath = (): string => listModelPathCandidates()[0] diff --git a/src/renderer/src/components/workbench/WorkspaceFileExplorer.tsx b/src/renderer/src/components/workbench/WorkspaceFileExplorer.tsx index 5f5bffc1..014e646f 100644 --- a/src/renderer/src/components/workbench/WorkspaceFileExplorer.tsx +++ b/src/renderer/src/components/workbench/WorkspaceFileExplorer.tsx @@ -326,7 +326,7 @@ const WorkspaceFileExplorer: React.FC = ({ ) }) ) : ( -
本会话暂无文件改动
+
本次运行暂无文件改动
)} diff --git a/src/renderer/src/components/workbench/applyTargetUtils.ts b/src/renderer/src/components/workbench/applyTargetUtils.ts index fdb7a589..76019b43 100644 --- a/src/renderer/src/components/workbench/applyTargetUtils.ts +++ b/src/renderer/src/components/workbench/applyTargetUtils.ts @@ -1,4 +1,5 @@ import type { AiFileChangeRecord } from '../../../../shared/types' +import { groupFileChangeRecordsByRun } from '../../../../shared/aiFileChangeScope' import type { AssistantTurnApplyTarget } from './types' export const buildApplyTarget = (record: AiFileChangeRecord): AssistantTurnApplyTarget => ({ @@ -23,12 +24,7 @@ export const mergeApplyTargets = ( export const groupApplyTargetsByRunId = ( records: AiFileChangeRecord[] ): Map => { - const grouped = new Map() - records.forEach((record) => { - const existing = grouped.get(record.runId) || [] - existing.push(record) - grouped.set(record.runId, existing) - }) + const grouped = groupFileChangeRecordsByRun(records) return new Map( [...grouped.entries()].map(([runId, runRecords]) => [ diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index a85de054..3c6ec942 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -96,6 +96,13 @@ import { groupApplyTargetsByRunId, mergeApplyTargets } from '../components/workbench/applyTargetUtils' +import { + createFileChangeListRequest, + createFileChangeRunScope, + filterFileChangeRecordsForScope, + isCurrentFileChangeRunScope, + type FileChangeRunScope +} from '../../../shared/aiFileChangeScope' import { chatSelectors, workbenchSelectors } from '../testing/selectors' import SidebarPanelIcon from '../components/SidebarPanelIcon' import brandIcon from '../../../../logo/png/ncurator-logo-24.png' @@ -667,7 +674,7 @@ const ChatPage: React.FC = () => { const [activeApplyRecord, setActiveApplyRecord] = useState(null) const [fileChangeRecords, setFileChangeRecords] = useState([]) - const fileChangeRunScopeRef = useRef<{ sessionId: string; runId: string } | null>(null) + const fileChangeRunScopeRef = useRef(null) const [applyLoading, setApplyLoading] = useState(false) const [applyReverting, setApplyReverting] = useState(false) const [applyRevertConfirm, setApplyRevertConfirm] = useState(null) @@ -1512,19 +1519,13 @@ const ChatPage: React.FC = () => { } setActiveApplyRecord(nextRecord) - if ( - fileChangeRunScopeRef.current?.sessionId === nextRecord.sessionId && - fileChangeRunScopeRef.current.runId === nextRecord.runId - ) { + if (isCurrentFileChangeRunScope(fileChangeRunScopeRef.current, nextRecord)) { setFileChangeRecords((current) => current.map((record) => (record.id === nextRecord.id ? nextRecord : record)) ) } - if ( - fileChangeRunScopeRef.current?.sessionId !== nextRecord.sessionId || - fileChangeRunScopeRef.current.runId !== nextRecord.runId - ) { + if (!isCurrentFileChangeRunScope(fileChangeRunScopeRef.current, nextRecord)) { void loadFileChangesForSessionRef.current(nextRecord.sessionId, nextRecord.runId, { preferredApplyId: nextRecord.id, skipApplyRecordReload: true @@ -1556,7 +1557,8 @@ const ChatPage: React.FC = () => { ): Promise => { const requestId = fileChangeLoadRequestRef.current + 1 fileChangeLoadRequestRef.current = requestId - fileChangeRunScopeRef.current = { sessionId, runId } + const scope = createFileChangeRunScope({ sessionId, runId }) + fileChangeRunScopeRef.current = scope try { const shouldWaitForRecords = Boolean(options?.forceOpenWorkspace) || Boolean(options?.preferredApplyId) @@ -1565,8 +1567,8 @@ const ChatPage: React.FC = () => { let activeRecords: AiFileChangeRecord[] = [] for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const response = await window.api.aiFileChangeList({ sessionId, runId }) - const listedItems = sortFileChangeRecords(response.items) + const response = await window.api.aiFileChangeList(createFileChangeListRequest(scope)) + const listedItems = sortFileChangeRecords(filterFileChangeRecordsForScope(response.items, scope)) const noEffectiveChangeRecords = listedItems.filter( (item) => item.status === 'failed' && item.error === NO_EFFECTIVE_FILE_CHANGE_MESSAGE ) @@ -2402,8 +2404,10 @@ const ChatPage: React.FC = () => { if ( eventSessionId && eventSessionId === currentSessionIdRef.current && - fileChangeRunScopeRef.current?.sessionId === eventSessionId && - fileChangeRunScopeRef.current.runId === event.runId + isCurrentFileChangeRunScope(fileChangeRunScopeRef.current, { + sessionId: eventSessionId, + runId: event.runId + }) ) { void loadFileChangesForSession(eventSessionId, event.runId) } @@ -2439,8 +2443,10 @@ const ChatPage: React.FC = () => { if ( eventSessionId && eventSessionId === currentSessionIdRef.current && - fileChangeRunScopeRef.current?.sessionId === eventSessionId && - fileChangeRunScopeRef.current.runId === event.runId + isCurrentFileChangeRunScope(fileChangeRunScopeRef.current, { + sessionId: eventSessionId, + runId: event.runId + }) ) { void loadFileChangesForSession(eventSessionId, event.runId) } @@ -2459,8 +2465,10 @@ const ChatPage: React.FC = () => { if ( eventSessionId && eventSessionId === currentSessionIdRef.current && - fileChangeRunScopeRef.current?.sessionId === eventSessionId && - fileChangeRunScopeRef.current.runId === event.runId + isCurrentFileChangeRunScope(fileChangeRunScopeRef.current, { + sessionId: eventSessionId, + runId: event.runId + }) ) { void loadFileChangesForSession(eventSessionId, event.runId, { notifyNoEffectiveChange: true diff --git a/src/shared/aiFileChangeScope.ts b/src/shared/aiFileChangeScope.ts new file mode 100644 index 00000000..08ed6784 --- /dev/null +++ b/src/shared/aiFileChangeScope.ts @@ -0,0 +1,41 @@ +import type { AiFileChangeRecord, ListAiFileChangeRecordsRequest } from './types' + +export type FileChangeRunScope = { + sessionId: string + runId: string +} + +export const createFileChangeRunScope = (input: FileChangeRunScope): FileChangeRunScope => ({ + sessionId: input.sessionId, + runId: input.runId +}) + +export const isCurrentFileChangeRunScope = ( + scope: FileChangeRunScope | null, + input: FileChangeRunScope +): boolean => scope?.sessionId === input.sessionId && scope.runId === input.runId + +export const createFileChangeListRequest = ( + scope: FileChangeRunScope +): ListAiFileChangeRecordsRequest => ({ + sessionId: scope.sessionId, + runId: scope.runId +}) + +export const filterFileChangeRecordsForScope = ( + records: readonly AiFileChangeRecord[], + scope: FileChangeRunScope +): AiFileChangeRecord[] => + records.filter((record) => record.sessionId === scope.sessionId && record.runId === scope.runId) + +export const groupFileChangeRecordsByRun = ( + records: readonly AiFileChangeRecord[] +): Map => { + const grouped = new Map() + records.forEach((record) => { + const current = grouped.get(record.runId) || [] + current.push(record) + grouped.set(record.runId, current) + }) + return grouped +} diff --git a/tests/case/ai/tool/terminal/terminal-inline-edit-artifact-contract.test.ts b/tests/case/ai/tool/terminal/terminal-inline-edit-artifact-contract.test.ts index 05f503e4..541731d1 100644 --- a/tests/case/ai/tool/terminal/terminal-inline-edit-artifact-contract.test.ts +++ b/tests/case/ai/tool/terminal/terminal-inline-edit-artifact-contract.test.ts @@ -1,6 +1,11 @@ import fs from 'node:fs/promises' import path from 'node:path' import { describe, it } from 'vitest' +import type { AiRunEvent } from '../../../../../src/shared/types' +import type { TerminalToolContext } from '../../../../../src/main/services/ai/types' +import { createHumanRuntimeCache } from '../../../../../src/main/services/ai/human/node/utils/runtimeCache' +import { buildTerminalCapabilityTools } from '../../../../../src/main/services/ai/human/tools/terminal/terminalTools' +import type { TerminalSessionManager } from '../../../../../src/main/services/ai/human/tools/terminal/TerminalSessionManager' import { createTerminalToolHarness } from '../../../../harness/backend/aiToolHarness' import { runBackendCase } from '../../../../harness/backend/run-backend-case' import { filterEventsByType } from '../testHelpers' @@ -82,4 +87,88 @@ describe('ai tool terminal backend', () => { } }) }) + + it('terminal-tracking-start-failure-cleanup', async () => { + await runBackendCase({ + caseId: 'terminal-tracking-start-failure-cleanup', + harnessMode: 'tool-real', + domain: 'ai', + suite: 'tool', + groups: ['terminal'], + description: '验证终端启动失败时丢弃已创建的命令级文件追踪。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + const baseCtx = runtime.createAiRunContext() + const events: AiRunEvent[] = [] + const begunTrackingIds: string[] = [] + const discardedTrackingIds: string[] = [] + let finishCalls = 0 + const ctx = { + runId: runtime.runId, + platform: baseCtx.platform, + checkCancelled: baseCtx.checkCancelled, + humanRuntimeOverrides: baseCtx.humanRuntimeOverrides, + sendEvent: (event: AiRunEvent) => events.push(event), + requestApprovalBatch: baseCtx.requestApprovalBatch, + saveFileChangeRecord: baseCtx.saveFileChangeRecord, + beginTerminalCommandTracking: async ({ commandId }) => { + begunTrackingIds.push(commandId) + }, + finishTerminalCommandTracking: async () => { + finishCalls += 1 + return [] + }, + discardTerminalCommandTracking: (commandId) => { + discardedTrackingIds.push(commandId) + } + } satisfies TerminalToolContext + const failedSessionManager = { + list: () => [], + run: async () => { + throw new Error('terminal startup failed') + } + } as unknown as TerminalSessionManager + const terminalRun = buildTerminalCapabilityTools({ + ctx, + cache: createHumanRuntimeCache(), + runId: runtime.runId, + sessionId: 'test-session', + taskId: runtime.taskId, + workspaceRootPath: runtime.workspaceRoot, + workspaceId: 'test-workspace', + sessionManager: failedSessionManager, + aiResultTokenLimitRatio: 0.25, + emitActivity: () => undefined + }).tools.find((tool) => tool.name === 'terminal_run') + if (!terminalRun) throw new Error('terminal_run tool is missing') + const result = await terminalRun.invoke({ + command: isWindows ? 'Get-Location' : 'pwd', + commandExplanation: '验证终端启动失败时的追踪清理。' + }) + + return { + result, + begunTrackingIds, + discardedTrackingIds, + finishCalls, + artifactEvents: events.filter((event) => event.type === 'file_artifact') + } + }, + assert: async (result, runtime) => { + runtime.assertThat( + result.begunTrackingIds.length === 1 && + result.discardedTrackingIds.length === 1 && + result.discardedTrackingIds[0] === result.begunTrackingIds[0] && + result.finishCalls === 0 && + result.artifactEvents.length === 0, + { + name: 'terminal-startup-failure-discards-file-tracking', + message: '终端启动失败后必须丢弃命令快照,不得保存修改记录或发送文件 artifact。', + data: result + } + ) + } + }) + }) }) diff --git a/tests/case/ai/tool/terminal/terminal-persistent-session-contract.test.ts b/tests/case/ai/tool/terminal/terminal-persistent-session-contract.test.ts index 04630777..4d3593de 100644 --- a/tests/case/ai/tool/terminal/terminal-persistent-session-contract.test.ts +++ b/tests/case/ai/tool/terminal/terminal-persistent-session-contract.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { describe, it } from 'vitest' +import type { AiFileChangeRecord, AiRunEvent } from '../../../../../src/shared/types' import { createTerminalToolHarness } from '../../../../harness/backend/aiToolHarness' import { runBackendCase } from '../../../../harness/backend/run-backend-case' @@ -40,7 +41,7 @@ describe('ai tool terminal backend', () => { domain: 'ai', suite: 'tool', groups: ['terminal'], - description: '验证真实持久终端保留当前路径、长命令让出控制权、增量读取和停止。', + description: '验证真实持久终端保留当前路径、命令级文件追踪、长命令让出控制权和停止。', fixtures: [], runMode: 'single', execute: async (runtime) => { @@ -100,6 +101,18 @@ describe('ai tool terminal backend', () => { } throw new Error(`terminal output did not include ${expected}`) } + const waitForTrackedRecords = async ( + fileNames: readonly string[] + ): Promise => { + for (let attempt = 0; attempt < 60; attempt += 1) { + const records = await harness.ctx.listFileChangeRecords({ runId: runtime.runId }) + if (fileNames.every((fileName) => records.some((record) => record.fileName === fileName))) { + return records + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error(`terminal change records were not saved: ${fileNames.join(', ')}`) + } const changed = await waitForIdle( await harness.invoke('terminal_run', { command: isWindows ? 'Set-Location nested' : 'cd nested' @@ -116,17 +129,43 @@ describe('ai tool terminal backend', () => { command: isWindows ? 'Get-Location' : 'pwd' }) ) + const completedLongCommand = await waitForIdle( + await harness.invoke('terminal_run', { + terminalId: changed.terminalId, + command: isWindows + ? 'Set-Content -LiteralPath ..\\completed-from-nested.txt -Value completed; Start-Sleep -Milliseconds 500' + : 'printf "completed\\n" > ../completed-from-nested.txt; sleep 0.5' + }) + ) const snapshotsBeforeStop = harness.sessionManager.list({ chatSessionId: 'test-session' }) const running = await harness.invoke('terminal_run', { terminalId: changed.terminalId, command: isWindows - ? 'while ($true) { Write-Output "terminal-tick"; Start-Sleep -Milliseconds 50 }' - : 'while true; do echo terminal-tick; sleep 0.05; done' + ? 'Set-Content -LiteralPath ..\\stopped-from-nested.txt -Value stopped; while ($true) { Write-Output "terminal-tick"; Start-Sleep -Milliseconds 50 }' + : 'printf "stopped\\n" > ../stopped-from-nested.txt; while true; do echo terminal-tick; sleep 0.05; done' }) const read = await readUntilOutput(running, 'terminal-tick') const stopped = await harness.invoke('terminal_stop', { terminalId: changed.terminalId }) + const nonZero = await waitForIdle( + await harness.invoke('terminal_run', { + terminalId: changed.terminalId, + command: isWindows + ? 'Set-Content -LiteralPath ..\\nonzero-from-nested.txt -Value nonzero; cmd /c exit 1' + : 'printf "nonzero\\n" > ../nonzero-from-nested.txt; false' + }) + ) + const records = await waitForTrackedRecords([ + 'completed-from-nested.txt', + 'stopped-from-nested.txt', + 'nonzero-from-nested.txt' + ]) + const artifactEvents = runtime.eventCollector + .snapshot() + .filter((event): event is Extract => + event.type === 'file_artifact' + ) const detachedRejected = await harness.invoke('terminal_run', { command: isWindows ? 'Start-Process powershell' : 'sleep 1 &' }) @@ -136,9 +175,13 @@ describe('ai tool terminal backend', () => { changed, continued, fresh, + completedLongCommand, running, read, stopped, + nonZero, + records, + artifactEvents, detachedRejected, snapshotsBeforeStop, snapshotsAfterStop @@ -187,6 +230,31 @@ describe('ai tool terminal backend', () => { data: result } ) + const trackedFileNames = new Set(result.records.map((record) => record.fileName)) + const artifactFileNames = new Set(result.artifactEvents.map((event) => event.fileName)) + runtime.assertThat( + result.completedLongCommand.executionStatus === 'completed' && + result.nonZero.executionStatus === 'completed' && + result.nonZero.exitCode === 1 && + ['completed-from-nested.txt', 'stopped-from-nested.txt', 'nonzero-from-nested.txt'].every( + (fileName) => trackedFileNames.has(fileName) && artifactFileNames.has(fileName) + ) && + result.records + .filter((record) => trackedFileNames.has(record.fileName)) + .every((record) => record.source === 'terminal_tool' && record.runId === runtime.runId), + { + name: 'persistent-terminal-finalizes-command-scoped-file-changes', + message: + '长命令完成、停止和非零退出都必须按命令窗口保存文件变化并发出当前 run 的 artifact。', + data: { + completedLongCommand: result.completedLongCommand, + stopped: result.stopped, + nonZero: result.nonZero, + records: result.records, + artifactEvents: result.artifactEvents + } + } + ) } }) }) diff --git a/tests/case/unit/ai/file-change-run-scope.test.ts b/tests/case/unit/ai/file-change-run-scope.test.ts new file mode 100644 index 00000000..a493d43a --- /dev/null +++ b/tests/case/unit/ai/file-change-run-scope.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { AiFileChangeRecord } from '../../../../src/shared/types' +import { + createFileChangeListRequest, + filterFileChangeRecordsForScope, + groupFileChangeRecordsByRun, + isCurrentFileChangeRunScope +} from '../../../../src/shared/aiFileChangeScope' + +const record = (input: Partial): AiFileChangeRecord => ({ + id: input.id || 'change-1', + sessionId: input.sessionId || 'session-a', + runId: input.runId || 'run-a', + status: 'applied', + capability: 'text_file', + path: input.path || '/workspace/file.txt', + fileName: input.fileName || 'file.txt', + operation: 'updated', + changeCount: 1, + insertCount: 1, + deleteCount: 0, + createdAt: 1, + updatedAt: input.updatedAt || 1 +}) + +describe('ai unit file change run scope', () => { + it('keeps the right panel request and records within one session and run', () => { + const scope = { sessionId: 'session-a', runId: 'run-a' } + const records = [ + record({ id: 'a', sessionId: 'session-a', runId: 'run-a', path: '/workspace/a.txt' }), + record({ id: 'b', sessionId: 'session-a', runId: 'run-b', path: '/workspace/b.txt' }), + record({ id: 'c', sessionId: 'session-b', runId: 'run-a', path: '/workspace/c.txt' }) + ] + + expect(createFileChangeListRequest(scope)).toEqual({ sessionId: 'session-a', runId: 'run-a' }) + expect(filterFileChangeRecordsForScope(records, scope).map((item) => item.id)).toEqual(['a']) + }) + + it('ignores completion events for another session or run', () => { + const scope = { sessionId: 'session-a', runId: 'run-a' } + + expect(isCurrentFileChangeRunScope(scope, scope)).toBe(true) + expect(isCurrentFileChangeRunScope(scope, { sessionId: 'session-a', runId: 'run-b' })).toBe( + false + ) + expect(isCurrentFileChangeRunScope(scope, { sessionId: 'session-b', runId: 'run-a' })).toBe( + false + ) + }) + + it('keeps assistant history entries grouped by their own run', () => { + const groups = groupFileChangeRecordsByRun([ + record({ id: 'a', runId: 'run-a', path: '/workspace/a.txt', updatedAt: 2 }), + record({ id: 'b', runId: 'run-b', path: '/workspace/b.txt', updatedAt: 3 }) + ]) + + expect(groups.get('run-a')?.map((item) => item.id)).toEqual(['a']) + expect(groups.get('run-b')?.map((item) => item.id)).toEqual(['b']) + }) +}) diff --git a/tests/case/unit/ai/model-path-candidates.test.ts b/tests/case/unit/ai/model-path-candidates.test.ts index 13fd47a6..8e313059 100644 --- a/tests/case/unit/ai/model-path-candidates.test.ts +++ b/tests/case/unit/ai/model-path-candidates.test.ts @@ -2,11 +2,36 @@ import os from 'os' import path from 'path' import { describe, expect, it } from 'vitest' import { + getLanceDbPath, + getSqliteDbPath, + getUserDataPath, listKnownPersistentUserDataPaths, listModelPathCandidates } from '../../../../src/main/utils/paths' describe('ai unit model path candidates', () => { + it('resolves user data paths when called instead of at module load time', () => { + const original = process.env.NCURATOR_USER_DATA_PATH + const firstPath = path.join(os.tmpdir(), 'ncurator-paths-first') + const secondPath = path.join(os.tmpdir(), 'ncurator-paths-second') + + try { + process.env.NCURATOR_USER_DATA_PATH = firstPath + expect(getUserDataPath()).toBe(firstPath) + expect(getSqliteDbPath()).toBe(path.join(firstPath, 'app.sqlite')) + expect(getLanceDbPath()).toBe(path.join(firstPath, 'lancedb')) + + process.env.NCURATOR_USER_DATA_PATH = secondPath + expect(getUserDataPath()).toBe(secondPath) + } finally { + if (original === undefined) { + delete process.env.NCURATOR_USER_DATA_PATH + } else { + process.env.NCURATOR_USER_DATA_PATH = original + } + } + }) + it('includes known persistent macOS user data directories', () => { const homeDir = path.join('/Users', 'tester') const candidates = listKnownPersistentUserDataPaths('darwin', homeDir) diff --git a/tests/case/unit/ai/persistence/chat-run-service-contract.test.ts b/tests/case/unit/ai/persistence/chat-run-service-contract.test.ts index daaa0bee..05042def 100644 --- a/tests/case/unit/ai/persistence/chat-run-service-contract.test.ts +++ b/tests/case/unit/ai/persistence/chat-run-service-contract.test.ts @@ -1,9 +1,11 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatRunService } from '../../../../../src/main/services/ai/ChatRunService' +import { AiRunService } from '../../../../../src/main/services/ai/AiRunService' import { AiRunResourceManager } from '../../../../../src/main/services/ai/run/AiRunResourceManager' +import { createCompletedAiRunState } from '../../../../../src/main/services/ai/run/state' import { CONVERSATION_MEMORY_RECENT_TURNS, CONVERSATION_MEMORY_SUMMARY_TRIGGER_TURNS @@ -689,6 +691,35 @@ describe('unit ai persistence backend', () => { } }) + it('does-not-attribute-uncontrolled-workspace-changes-when-a-run-finishes', async () => { + const setup = await createTestService({ autoStartRuns: true }) + const runner = vi.spyOn(AiRunService, 'getInstance').mockReturnValue({ + run: async (payload) => { + await writeFile(path.join(setup.workspaceRoot, 'uncontrolled-change.txt'), 'external change') + return createCompletedAiRunState({ + runId: payload.runId, + sessionId: payload.sessionId, + input: payload.input, + finalDeliveryMarkdown: '完成,但没有调用文件工具或终端。' + }) + } + } as unknown as AiRunService) + try { + const run = await submitAiRun(setup) + await waitForCondition(() => setup.events.some((event) => event.type === 'run_completed')) + + const records = await setup.storage.aiFileChange.listRecords({ + sessionId: run.sessionId, + runId: run.id + }) + expect(await access(path.join(setup.workspaceRoot, 'uncontrolled-change.txt')).then(() => true)).toBe(true) + expect(records.items).toEqual([]) + expect(setup.events.filter((event) => event.type === 'file_artifact')).toEqual([]) + } finally { + runner.mockRestore() + } + }) + it('approval-required-is-restored-through-session-snapshot', async () => { const setup = await createTestService() const run = await submitAiRun(setup) diff --git a/tests/case/unit/vector/vector-download-single-task-contract.test.ts b/tests/case/unit/vector/vector-download-single-task-contract.test.ts index 97fc7734..6a01ff96 100644 --- a/tests/case/unit/vector/vector-download-single-task-contract.test.ts +++ b/tests/case/unit/vector/vector-download-single-task-contract.test.ts @@ -7,7 +7,7 @@ import type { ModelDownloadProgressReporter } from '../../../../src/main/services/model/ModelService' import { runBackendCase } from '../../../harness/backend/run-backend-case' -import { MODELS_PATH } from '../../../../src/main/utils/paths' +import { getModelsPath } from '../../../../src/main/utils/paths' import { TransformersEmbeddingExecutor } from '../../../../src/main/services/vector/runtime/TransformersEmbeddingExecutor' const REPO_A = 'jinaai/jina-embeddings-v2-base-zh' @@ -36,7 +36,7 @@ describe('unit vector backend', () => { const modelService = ModelService.getInstance() const embeddingService = new EmbeddingService({ executor: new TransformersEmbeddingExecutor({ - modelsPath: MODELS_PATH, + modelsPath: getModelsPath(), modelName: REPO_A }) }) diff --git a/tests/case/unit/vector/vector-download-startup-resume-contract.test.ts b/tests/case/unit/vector/vector-download-startup-resume-contract.test.ts index 69fdeee5..22e1fc5b 100644 --- a/tests/case/unit/vector/vector-download-startup-resume-contract.test.ts +++ b/tests/case/unit/vector/vector-download-startup-resume-contract.test.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' import { afterEach, describe, it, vi } from 'vitest' -import { MODELS_PATH } from '../../../../src/main/utils/paths' +import { getModelsPath } from '../../../../src/main/utils/paths' import { EmbeddingService } from '../../../../src/main/services/vector/EmbeddingService' import { ModelService } from '../../../../src/main/services/model/ModelService' import { VectorModelDownloadService } from '../../../../src/main/services/model/VectorModelDownloadService' @@ -19,7 +19,7 @@ type VectorModelDownloadServiceStatics = typeof VectorModelDownloadService & { instance: VectorModelDownloadService | null } -const resolveSnapshotPath = (): string => path.join(MODELS_PATH, ACTIVE_DOWNLOAD_SNAPSHOT_FILE) +const resolveSnapshotPath = (): string => path.join(getModelsPath(), ACTIVE_DOWNLOAD_SNAPSHOT_FILE) const waitForCondition = async (predicate: () => boolean, timeoutMs = 5000): Promise => { const deadline = Date.now() + timeoutMs @@ -55,7 +55,7 @@ describe('unit vector backend', () => { const modelService = ModelService.getInstance() const embeddingService = new EmbeddingService({ executor: new TransformersEmbeddingExecutor({ - modelsPath: MODELS_PATH, + modelsPath: getModelsPath(), modelName: SNAPSHOT_REPO_ID }) }) @@ -69,7 +69,7 @@ describe('unit vector backend', () => { releaseDownload = resolve }) - await fs.promises.mkdir(MODELS_PATH, { recursive: true }) + await fs.promises.mkdir(getModelsPath(), { recursive: true }) await fs.promises.writeFile( resolveSnapshotPath(), JSON.stringify( diff --git a/tests/case/workbench/result/apply/file-change-run-scope.e2e.test.ts b/tests/case/workbench/result/apply/file-change-run-scope.e2e.test.ts new file mode 100644 index 00000000..4557145f --- /dev/null +++ b/tests/case/workbench/result/apply/file-change-run-scope.e2e.test.ts @@ -0,0 +1,172 @@ +import { describe, it } from 'vitest' +import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' +import { + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, + assertNoWorkbenchFakeSignals, + createWorkbenchNewSession, + getWorkbenchLocator, + recordWorkbenchAiEvidence, + selectWorkbenchWorkspace, + sendWorkbenchMessage, + setupWorkbenchWithTempWorkspace, + waitForWorkbenchAiRunCompletion, + workbenchSelectors +} from '../../../../harness/e2e/workbench' + +const CASE_ID = 'file-change-run-scope' +const FIRST_FILE = 'run-scope-first.txt' +const SECOND_FILE = 'run-scope-second.txt' + +const getRunStarted = (events: import('../../../../../src/shared/types').AiRunEvent[]) => { + const event = events.find( + (item): item is Extract => + item.type === 'run_started' + ) + if (!event) throw new Error('run_started event is missing') + return event +} + +const getApplyEntryForFile = async (page: import('playwright').Page, fileName: string) => { + const entry = page + .locator(`[data-testid^="${workbenchSelectors.assistantTurnApplyPrefix}-"]`) + .filter({ hasText: fileName }) + .first() + await entry.waitFor({ state: 'visible', timeout: 30_000 }) + return entry +} + +const readChangedFileList = async (page: import('playwright').Page) => { + await getWorkbenchLocator(page, workbenchSelectors.workspaceExplorerToggle).click() + const explorer = getWorkbenchLocator(page, workbenchSelectors.workspaceExplorerOverlay) + await explorer.waitFor({ state: 'visible', timeout: 30_000 }) + const text = ((await explorer.textContent()) || '').trim() + await page.keyboard.press('Escape') + return text +} + +describe('workbench result e2e', () => { + it( + CASE_ID, + async () => { + await runE2ECase({ + caseId: CASE_ID, + domain: 'workbench', + suite: 'result', + groups: ['apply'], + description: + '真实 UI 在不同会话的两个 run 分别写入文件后,右侧本次运行改动不得串入另一个 run 的记录。', + fixtures: ['tests/fixtures/workspace'], + serviceMode: 'real', + execution: 'live', + windowMode: 'visible', + runMode: 'single', + execute: async (runtime) => { + const { page } = runtime + const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID }) + + const firstStartedAtMs = Date.now() + await sendWorkbenchMessage( + page, + `请只使用 terminal_run,在工作区根目录执行 printf 'first-run' > ${FIRST_FILE}。不要修改其他文件。` + ) + const firstEvidence = await waitForWorkbenchAiRunCompletion( + runtime, + firstStartedAtMs, + setup.env.modelName, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, + { page, blockerPolicy: 'auto_approve' } + ) + recordWorkbenchAiEvidence(runtime, firstEvidence, 0.8) + const firstRun = getRunStarted(firstEvidence.events) + const firstEntry = await getApplyEntryForFile(page, FIRST_FILE) + const firstCardText = ((await firstEntry.textContent()) || '').trim() + await firstEntry.click() + const firstChangedFileList = await readChangedFileList(page) + + await createWorkbenchNewSession(page) + await selectWorkbenchWorkspace(page, setup.workspaceName) + const secondStartedAtMs = Date.now() + await sendWorkbenchMessage( + page, + `请只使用 terminal_run,在工作区根目录执行 printf 'second-run' > ${SECOND_FILE}。不要修改其他文件。` + ) + const secondEvidence = await waitForWorkbenchAiRunCompletion( + runtime, + secondStartedAtMs, + setup.env.modelName, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, + { page, blockerPolicy: 'auto_approve' } + ) + recordWorkbenchAiEvidence(runtime, secondEvidence, 0.8) + const secondRun = getRunStarted(secondEvidence.events) + const secondEntry = await getApplyEntryForFile(page, SECOND_FILE) + const secondCardText = ((await secondEntry.textContent()) || '').trim() + await secondEntry.click() + const secondChangedFileList = await readChangedFileList(page) + + await getWorkbenchLocator( + page, + `${workbenchSelectors.sessionItemPrefix}-${firstRun.sessionId}` + ).click() + const restoredFirstEntry = await getApplyEntryForFile(page, FIRST_FILE) + await restoredFirstEntry.click() + const restoredFirstChangedFileList = await readChangedFileList(page) + + return { + firstRunId: firstRun.runId, + secondRunId: secondRun.runId, + firstSessionId: firstRun.sessionId, + secondSessionId: secondRun.sessionId, + firstCardText, + secondCardText, + firstChangedFileList, + secondChangedFileList, + restoredFirstChangedFileList, + firstTerminalCalls: firstEvidence.events.filter( + (event) => event.type === 'tool_call_started' && event.toolName === 'terminal_run' + ).length, + secondTerminalCalls: secondEvidence.events.filter( + (event) => event.type === 'tool_call_started' && event.toolName === 'terminal_run' + ).length + } + }, + assert: async (result, runtime) => { + const firstListIsIsolated = + result.firstChangedFileList.includes('本次运行改动 1') && + result.firstChangedFileList.includes(FIRST_FILE) && + !result.firstChangedFileList.includes(SECOND_FILE) + const secondListIsIsolated = + result.secondChangedFileList.includes('本次运行改动 1') && + result.secondChangedFileList.includes(SECOND_FILE) && + !result.secondChangedFileList.includes(FIRST_FILE) + const restoredFirstListIsIsolated = + result.restoredFirstChangedFileList.includes('本次运行改动 1') && + result.restoredFirstChangedFileList.includes(FIRST_FILE) && + !result.restoredFirstChangedFileList.includes(SECOND_FILE) + + await runtime.assertThat( + result.firstRunId !== result.secondRunId && + result.firstSessionId !== result.secondSessionId && + result.firstCardText.includes(FIRST_FILE) && + !result.firstCardText.includes(SECOND_FILE) && + result.secondCardText.includes(SECOND_FILE) && + !result.secondCardText.includes(FIRST_FILE) && + firstListIsIsolated && + secondListIsIsolated && + restoredFirstListIsIsolated && + result.firstTerminalCalls >= 1 && + result.secondTerminalCalls >= 1, + { + name: 'workbench-file-change-run-scope', + message: + '每条 assistant 修改入口和右侧本次运行改动必须只显示自身 sessionId 与 runId 的文件记录。', + data: result + } + ) + await assertNoWorkbenchFakeSignals(runtime) + } + }) + }, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS * 2 + ) +}) diff --git a/tests/harness/backend/realAiDepsFactory.ts b/tests/harness/backend/realAiDepsFactory.ts index 053ff658..271670e1 100644 --- a/tests/harness/backend/realAiDepsFactory.ts +++ b/tests/harness/backend/realAiDepsFactory.ts @@ -17,7 +17,7 @@ import { TransformersEmbeddingExecutor } from '../../../src/main/services/vector import { ensureEmbeddingReady } from '../../../src/main/services/vector/ensureEmbeddingReady' import { WorkspaceKnowledgePrepareService } from '../../../src/main/services/workspace/WorkspaceKnowledgePrepareService' import { WorkspaceSourceService } from '../../../src/main/services/workspace/WorkspaceSourceService' -import { MODELS_PATH } from '../../../src/main/utils/paths' +import { getModelsPath } from '../../../src/main/utils/paths' import type { AiRuntimeSettings } from '../../../src/shared/types' import type { BackendHarnessMode, CreateAiRunContextInput, LiveLlmConfig } from './test-types' import type { EventCollector } from '../core/eventCollector' @@ -113,7 +113,7 @@ export const createRealAiDepsFactory = async (input: { const embeddingService = new EmbeddingService({ executor: new TransformersEmbeddingExecutor({ - modelsPath: MODELS_PATH, + modelsPath: getModelsPath(), modelName: 'jinaai/jina-embeddings-v2-base-zh' }) })