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.
This commit is contained in:
hy committed 2026-08-03 10:57:37 +08:00
1 parent 650b8de521
commit 00319a880b
22 files changed
+582 -64

No files matched your search

+13 -3
View File
@@ -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 类别
+9
View File
@@ -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`
+2 -2
View File
@@ -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({
+3 -3
View File
@@ -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 {
@@ -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')
}
@@ -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<string> | null = null
private tableCache = new Map<string, object>()
private tableWriteQueues = new Map<string, Promise<unknown>>()
public constructor(private readonly dbPath: string = LANCE_DB_PATH) {}
public constructor(dbPath?: string) {
this.dbPath = dbPath || getLanceDbPath()
}
public getStatus(): LanceDbCoreStatus {
return this.status
@@ -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
@@ -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 => {
+13 -10
View File
@@ -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]
@@ -326,7 +326,7 @@ const WorkspaceFileExplorer: React.FC<WorkspaceFileExplorerProps> = ({
)
})
) : (
<div className="px-3 py-8 text-center text-xs text-[#91918E]">本会话暂无文件改动</div>
<div className="px-3 py-8 text-center text-xs text-[#91918E]">本次运行暂无文件改动</div>
)}
</div>
</div>
@@ -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<string, AssistantTurnApplyTarget[]> => {
const grouped = new Map<string, AiFileChangeRecord[]>()
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]) => [
+26 -18
View File
@@ -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<AiFileChangeRecord | null>(null)
const [fileChangeRecords, setFileChangeRecords] = useState<AiFileChangeRecord[]>([])
const fileChangeRunScopeRef = useRef<{ sessionId: string; runId: string } | null>(null)
const fileChangeRunScopeRef = useRef<FileChangeRunScope | null>(null)
const [applyLoading, setApplyLoading] = useState(false)
const [applyReverting, setApplyReverting] = useState(false)
const [applyRevertConfirm, setApplyRevertConfirm] = useState<ApplyRevertConfirmState | null>(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<void> => {
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
+41
View File
@@ -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<string, AiFileChangeRecord[]> => {
const grouped = new Map<string, AiFileChangeRecord[]>()
records.forEach((record) => {
const current = grouped.get(record.runId) || []
current.push(record)
grouped.set(record.runId, current)
})
return grouped
}
@@ -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
}
)
}
})
})
})
@@ -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<AiFileChangeRecord[]> => {
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<void>((resolve) => setTimeout(resolve, 50))
}
throw new Error(`terminal change records were not saved: ${fileNames.join(', ')}`)
}
const changed = await waitForIdle(
await harness.invoke<TerminalRunResult>('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<TerminalRunResult>('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<TerminalRunResult>('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<TerminalStopResult>('terminal_stop', {
terminalId: changed.terminalId
})
const nonZero = await waitForIdle(
await harness.invoke<TerminalRunResult>('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<AiRunEvent, { type: 'file_artifact' }> =>
event.type === 'file_artifact'
)
const detachedRejected = await harness.invoke<TerminalFailureResult>('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
}
}
)
}
})
})
@@ -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>): 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'])
})
})
@@ -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)
@@ -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)
@@ -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
})
})
@@ -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<void> => {
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(
@@ -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<import('../../../../../src/shared/types').AiRunEvent, { type: 'run_started' }> =>
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
)
})
+2 -2
View File
@@ -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'
})
})