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:
1 parent
650b8de521
commit
00319a880b
22 files changed
+582
-64
No files matched your search
@@ -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 类别
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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]) => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user