mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-18 13:13:32 +00:00
feat: preview run artifact changes
This commit is contained in:
parent
101befd9a8
commit
b04f7887ea
7 changed files with 424 additions and 60 deletions
|
|
@ -22,10 +22,19 @@ from pathlib import Path
|
|||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.auth import require_local_control_principal
|
||||
from app.component.environment import env
|
||||
from app.utils.file_utils import list_files, resolve_under_base
|
||||
from app.utils.workspace_paths import runtime_owner_key, task_dir_name
|
||||
|
|
@ -206,6 +215,87 @@ def _resolve_file_root(
|
|||
return _resolve_project_root(email, project_id, user_id)
|
||||
|
||||
|
||||
def _task_change_roots(snapshot) -> list[tuple[Path, bool]]:
|
||||
"""Return task roots as ``(path, include_all)`` tuples.
|
||||
|
||||
The output root is Run-owned, so every file there is an artifact even when
|
||||
a copy operation preserves an old mtime. A distinct working directory may
|
||||
contain user files; only files modified after Run admission are surfaced.
|
||||
"""
|
||||
output_root = Path(snapshot.task_output_root).expanduser().resolve()
|
||||
working_root = Path(snapshot.working_directory).expanduser().resolve()
|
||||
roots: list[tuple[Path, bool]] = []
|
||||
if output_root.is_dir():
|
||||
roots.append((output_root, True))
|
||||
if working_root != output_root and working_root.is_dir():
|
||||
roots.append((working_root, False))
|
||||
return roots
|
||||
|
||||
|
||||
def _list_task_changed_files(snapshot, max_entries: int = 500) -> list[dict]:
|
||||
"""List files generated or modified by one Run without uploading them."""
|
||||
result: list[dict] = []
|
||||
seen_paths: set[str] = set()
|
||||
remaining = max_entries
|
||||
# Account for filesystems whose mtimes have one-second resolution.
|
||||
modified_after = snapshot.task_start_time - 1.0
|
||||
|
||||
for root, include_all in _task_change_roots(snapshot):
|
||||
if remaining <= 0:
|
||||
break
|
||||
paths = list_files(
|
||||
str(root),
|
||||
base=str(root),
|
||||
max_entries=remaining,
|
||||
modified_after=None if include_all else modified_after,
|
||||
)
|
||||
for abs_path in paths:
|
||||
try:
|
||||
path = Path(abs_path).resolve()
|
||||
if not path.is_file():
|
||||
continue
|
||||
identity = str(path)
|
||||
if identity in seen_paths:
|
||||
continue
|
||||
relative_path = path.relative_to(root).as_posix()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
seen_paths.add(identity)
|
||||
remaining -= 1
|
||||
result.append(
|
||||
{
|
||||
"filename": path.name,
|
||||
"path": identity,
|
||||
"relativePath": relative_path,
|
||||
"changeType": "generated" if include_all else "changed",
|
||||
}
|
||||
)
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
return sorted(result, key=lambda item: item["relativePath"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/changes",
|
||||
dependencies=[Depends(require_local_control_principal)],
|
||||
)
|
||||
async def list_task_changed_files(
|
||||
task_id: str = Query(..., description="Run/task ID"),
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
email: str = Query(..., description="User email"),
|
||||
user_id: str | None = Query(None, description="Optional canonical user ID"),
|
||||
) -> list[dict]:
|
||||
"""Return the Desktop-local preview index for one Run's changed files."""
|
||||
snapshot = get_workspace_resolver().store.get_snapshot(
|
||||
email, task_id, user_id
|
||||
)
|
||||
if snapshot is None or snapshot.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Task workspace not found")
|
||||
return await run_in_threadpool(_list_task_changed_files, snapshot)
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_project_files(
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ def list_files(
|
|||
skip_dirs: set[str] | None = None,
|
||||
skip_extensions: tuple[str, ...] = DEFAULT_SKIP_EXTENSIONS,
|
||||
skip_prefix: str = ".",
|
||||
modified_after: float | None = None,
|
||||
stats: dict[str, float | int] | None = None,
|
||||
) -> list[str]:
|
||||
"""List files under dir_path with optional base confinement and filters.
|
||||
|
|
@ -209,6 +210,9 @@ def list_files(
|
|||
skip_dirs (set[str] | None): Directory names to skip (default: DEFAULT_SKIP_DIRS).
|
||||
skip_extensions (tuple[str, ...]): File extensions to skip (default: DEFAULT_SKIP_EXTENSIONS).
|
||||
skip_prefix (str): Skip dirs/files whose name starts with this prefix.
|
||||
modified_after (float | None): If set, only include files whose mtime
|
||||
is at or after this Unix timestamp. Filtering happens before the
|
||||
result limit so recent artifacts are not hidden by older files.
|
||||
|
||||
Returns:
|
||||
List of real absolute file paths under dir_path (subject to filters and max_entries).
|
||||
|
|
@ -256,6 +260,12 @@ def list_files(
|
|||
continue
|
||||
try:
|
||||
file_path = os.path.join(root, name)
|
||||
if (
|
||||
modified_after is not None
|
||||
and os.stat(file_path, follow_symlinks=False).st_mtime
|
||||
< modified_after
|
||||
):
|
||||
continue
|
||||
if os.path.islink(file_path):
|
||||
symlink_count += 1
|
||||
realpath_started = time.perf_counter()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@
|
|||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth.local_control import LOCAL_CONTROL_CAPABILITY_HEADER
|
||||
from app.controller import file_controller
|
||||
|
||||
|
||||
|
|
@ -87,3 +96,85 @@ def test_resolve_project_root_without_user_id_stays_email_scoped(
|
|||
)
|
||||
|
||||
assert resolved == expected
|
||||
|
||||
|
||||
def test_task_changes_include_all_outputs_but_only_recent_workspace_edits(
|
||||
tmp_path,
|
||||
):
|
||||
output_root = tmp_path / "outputs"
|
||||
working_root = tmp_path / "workspace"
|
||||
output_root.mkdir()
|
||||
working_root.mkdir()
|
||||
started_at = time.time()
|
||||
|
||||
copied_output = output_root / "copied-report.csv"
|
||||
copied_output.write_text("output", encoding="utf-8")
|
||||
old_workspace_file = working_root / "existing.md"
|
||||
old_workspace_file.write_text("old", encoding="utf-8")
|
||||
old_time = started_at - 60
|
||||
os.utime(copied_output, (old_time, old_time))
|
||||
os.utime(old_workspace_file, (old_time, old_time))
|
||||
# Old files must be filtered before the 500-item result bound. Otherwise
|
||||
# a large selected folder can hide a recent artifact later in the walk.
|
||||
for index in range(510):
|
||||
old_file = working_root / f"old-{index:03d}.txt"
|
||||
old_file.write_text("old", encoding="utf-8")
|
||||
os.utime(old_file, (old_time, old_time))
|
||||
edited_workspace_file = working_root / "reports" / "final.md"
|
||||
edited_workspace_file.parent.mkdir()
|
||||
edited_workspace_file.write_text("new", encoding="utf-8")
|
||||
|
||||
files = file_controller._list_task_changed_files(
|
||||
SimpleNamespace(
|
||||
task_output_root=str(output_root),
|
||||
working_directory=str(working_root),
|
||||
task_start_time=started_at,
|
||||
)
|
||||
)
|
||||
|
||||
assert {item["path"] for item in files} == {
|
||||
str(copied_output.resolve()),
|
||||
str(edited_workspace_file.resolve()),
|
||||
}
|
||||
assert {item["relativePath"] for item in files} == {
|
||||
"copied-report.csv",
|
||||
"reports/final.md",
|
||||
}
|
||||
assert {item["path"]: item["changeType"] for item in files} == {
|
||||
str(copied_output.resolve()): "generated",
|
||||
str(edited_workspace_file.resolve()): "changed",
|
||||
}
|
||||
|
||||
|
||||
def test_task_changes_endpoint_requires_local_capability(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
|
||||
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
|
||||
snapshot = SimpleNamespace(
|
||||
project_id="project-1",
|
||||
task_output_root=str(tmp_path),
|
||||
working_directory=str(tmp_path),
|
||||
task_start_time=time.time(),
|
||||
)
|
||||
resolver = MagicMock()
|
||||
resolver.store.get_snapshot.return_value = snapshot
|
||||
monkeypatch.setattr(
|
||||
file_controller, "get_workspace_resolver", lambda: resolver
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(file_controller.router)
|
||||
client = TestClient(app, client=("127.0.0.1", 50000))
|
||||
params = {
|
||||
"task_id": "task-1",
|
||||
"project_id": "project-1",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
|
||||
assert client.get("/files/changes", params=params).status_code == 401
|
||||
response = client.get(
|
||||
"/files/changes",
|
||||
params=params,
|
||||
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
|
|
|||
|
|
@ -79,6 +79,63 @@ const AgentResultCard: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
/** Run-scoped artifact delta. Every item opens in the existing preview panel. */
|
||||
const ArtifactChangeList: React.FC<{
|
||||
files?: FileInfo[];
|
||||
onOpen: (file: FileInfo) => void;
|
||||
}> = ({ files, onOpen }) => {
|
||||
if (!files?.length) return null;
|
||||
|
||||
return (
|
||||
<section className="my-3 rounded-xl border border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-label-sm font-semibold text-ds-text-neutral-default-default">
|
||||
<FileText size={15} aria-hidden />
|
||||
<span>Files changed</span>
|
||||
<span className="bg-ds-bg-neutral-secondary-default rounded-full px-2 py-0.5 text-label-xs font-medium text-ds-text-neutral-muted-default">
|
||||
{files.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto sm:grid-cols-2">
|
||||
{files.map((file, fileIndex) => {
|
||||
const detail = file.relativePath || file.path || file.name;
|
||||
const changeLabel =
|
||||
file.artifactChange === 'generated'
|
||||
? 'Generated'
|
||||
: file.artifactChange === 'changed'
|
||||
? 'Changed'
|
||||
: file.type || 'File';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`artifact-${detail}-${fileIndex}`}
|
||||
title={detail}
|
||||
onClick={() => onOpen(file)}
|
||||
className="bg-ds-bg-neutral-primary-default flex min-w-0 items-center gap-3 rounded-lg border border-ds-border-neutral-default-default px-3 py-2 text-left transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
>
|
||||
<FileText
|
||||
size={18}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ds-icon-neutral-default-default"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-semibold text-ds-text-neutral-default-default">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="block truncate text-label-xs text-ds-text-neutral-muted-default">
|
||||
{file.relativePath || changeLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-label-xs font-medium text-ds-text-neutral-muted-default">
|
||||
{changeLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/** Typewriter only for the agent message currently being produced (latest agent row while task is running). */
|
||||
function shouldUseLiveAgentTypewriter(
|
||||
task: {
|
||||
|
|
@ -429,35 +486,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
onTyping={() => {}}
|
||||
deferredFooter={
|
||||
message.fileList?.length ? (
|
||||
<div className="my-2 flex flex-wrap gap-2">
|
||||
{message.fileList.map(
|
||||
(file: any, fileIndex: number) => (
|
||||
<motion.div
|
||||
key={`file-${message.id}-${file.name}-${fileIndex}`}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
onClick={() => {
|
||||
openFilePreview(file);
|
||||
}}
|
||||
className="flex w-[140px] cursor-pointer items-center gap-2 rounded-lg bg-ds-bg-neutral-default-default px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className="flex-shrink-0 text-ds-icon-neutral-default-default"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{file.name.split('.')[0]}
|
||||
</div>
|
||||
<div className="text-label-xs font-medium text-ds-text-neutral-muted-default">
|
||||
{file.type}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<ArtifactChangeList
|
||||
files={message.fileList}
|
||||
onOpen={openFilePreview}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -527,35 +559,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{message.fileList && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.fileList.map((file: any, fileIndex: number) => (
|
||||
<motion.div
|
||||
key={`file-${message.id}-${file.name}-${fileIndex}`}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
onClick={() => {
|
||||
openFilePreview(file);
|
||||
}}
|
||||
className="flex w-[120px] cursor-pointer items-center gap-2 rounded-2xl bg-ds-bg-neutral-default-default px-2 py-1 transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className="flex-shrink-0 text-ds-icon-neutral-default-default"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-body max-w-48 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-bold text-ds-text-neutral-default-default">
|
||||
{file.name.split('.')[0]}
|
||||
</div>
|
||||
<div className="text-xs font-medium leading-29 text-ds-text-neutral-default-default">
|
||||
{file.type}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ArtifactChangeList
|
||||
files={message.fileList}
|
||||
onOpen={openFilePreview}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import {
|
||||
fetchDelete,
|
||||
fetchGet,
|
||||
fetchPost,
|
||||
fetchPut,
|
||||
getBaseURL,
|
||||
|
|
@ -1033,6 +1034,81 @@ export function extractFinalOutputFileList(
|
|||
return fileInfos;
|
||||
}
|
||||
|
||||
type TaskArtifactChange = {
|
||||
filename?: unknown;
|
||||
path?: unknown;
|
||||
relativePath?: unknown;
|
||||
changeType?: unknown;
|
||||
};
|
||||
|
||||
/** Convert Brain's capability-protected local artifact index into preview cards. */
|
||||
export function normalizeTaskArtifactFileList(value: unknown): FileInfo[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const files: FileInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of value as TaskArtifactChange[]) {
|
||||
const path =
|
||||
typeof candidate.path === 'string'
|
||||
? normalizeOutputPath(candidate.path)
|
||||
: '';
|
||||
const name =
|
||||
typeof candidate.filename === 'string'
|
||||
? candidate.filename.trim()
|
||||
: getOutputFileNameFromPath(path);
|
||||
const relativePath =
|
||||
typeof candidate.relativePath === 'string'
|
||||
? normalizeOutputPath(candidate.relativePath)
|
||||
: undefined;
|
||||
const type = getFileTypeFromName(name);
|
||||
const identity = (relativePath || path).toLowerCase();
|
||||
if (!path || !name || !identity || seen.has(identity)) continue;
|
||||
|
||||
seen.add(identity);
|
||||
files.push({
|
||||
name,
|
||||
type,
|
||||
path,
|
||||
relativePath,
|
||||
icon: FileText,
|
||||
isRemote: false,
|
||||
artifactChange:
|
||||
candidate.changeType === 'generated' ? 'generated' : 'changed',
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function loadTaskArtifactFileList({
|
||||
taskId,
|
||||
projectId,
|
||||
email,
|
||||
userId,
|
||||
}: {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
userId?: string | number | null;
|
||||
}): Promise<FileInfo[]> {
|
||||
// The index contains absolute local paths and is intentionally Desktop-only.
|
||||
if (!getHostIpcRenderer()?.invoke || !projectId || !email) return [];
|
||||
|
||||
try {
|
||||
const changes = await fetchGet('/files/changes', {
|
||||
task_id: taskId,
|
||||
project_id: projectId,
|
||||
email,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
});
|
||||
return normalizeTaskArtifactFileList(changes);
|
||||
} catch (error) {
|
||||
// Older Brain versions and cloud-only history do not expose the local
|
||||
// artifact index. Existing WRITE_FILE/final-answer projections remain.
|
||||
console.info(`[Artifacts] No local changes for task ${taskId}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getFileInfoIdentities(file: FileInfo): string[] {
|
||||
return [
|
||||
file.relativePath,
|
||||
|
|
@ -1082,6 +1158,15 @@ export function mergeFileInfoLists(
|
|||
...file,
|
||||
};
|
||||
mergedIdentities[existingIndex] = identities;
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.artifactChange && !existingFile.artifactChange) {
|
||||
merged[existingIndex] = {
|
||||
...existingFile,
|
||||
artifactChange: file.artifactChange,
|
||||
relativePath: existingFile.relativePath || file.relativePath,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -4152,6 +4237,12 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
|
||||
const outputProjectId =
|
||||
project_id || projectStore.activeProjectId || undefined;
|
||||
const taskArtifactFileList = await loadTaskArtifactFileList({
|
||||
taskId: currentTaskId,
|
||||
projectId: outputProjectId,
|
||||
email: email || undefined,
|
||||
userId: user_id,
|
||||
});
|
||||
const outputBaseURL = await getBaseURL().catch(() => '');
|
||||
const finalOutputFileList = extractFinalOutputFileList(
|
||||
endMessage,
|
||||
|
|
@ -4160,7 +4251,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
outputBaseURL || undefined
|
||||
);
|
||||
const mergedFileList = mergeFileInfoLists(
|
||||
fileList,
|
||||
mergeFileInfoLists(fileList, taskArtifactFileList),
|
||||
finalOutputFileList
|
||||
);
|
||||
|
||||
|
|
|
|||
1
src/types/chatbox.d.ts
vendored
1
src/types/chatbox.d.ts
vendored
|
|
@ -34,6 +34,7 @@ declare global {
|
|||
isFolder?: boolean;
|
||||
isRemote?: boolean;
|
||||
relativePath?: string;
|
||||
artifactChange?: 'generated' | 'changed';
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ vi.mock('@/api/http', async () => {
|
|||
const getBaseURL = vi.fn(() => Promise.resolve('http://localhost:8000'));
|
||||
|
||||
return {
|
||||
fetchGet: vi.fn(),
|
||||
fetchPost: vi.fn(),
|
||||
fetchPut: vi.fn(),
|
||||
getBaseURL,
|
||||
|
|
@ -135,6 +136,7 @@ import {
|
|||
extractFinalOutputFileList,
|
||||
getCloudModelPlatform,
|
||||
mergeFileInfoLists,
|
||||
normalizeTaskArtifactFileList,
|
||||
resolveConfirmedUserMessageContent,
|
||||
resolveEndMessageText,
|
||||
useChatStore,
|
||||
|
|
@ -255,6 +257,78 @@ describe('ChatStore - Core Functionality', () => {
|
|||
});
|
||||
|
||||
describe('Final output file extraction', () => {
|
||||
it('normalizes the local artifact change index into previewable files', () => {
|
||||
const files = normalizeTaskArtifactFileList([
|
||||
{
|
||||
filename: 'final_report.md',
|
||||
path: '/Users/test/project/reports/final_report.md',
|
||||
relativePath: 'reports/final_report.md',
|
||||
changeType: 'changed',
|
||||
},
|
||||
{
|
||||
filename: 'chart.png',
|
||||
path: '/Users/test/outputs/chart.png',
|
||||
relativePath: 'chart.png',
|
||||
changeType: 'generated',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(files).toMatchObject([
|
||||
{
|
||||
name: 'final_report.md',
|
||||
type: 'md',
|
||||
relativePath: 'reports/final_report.md',
|
||||
artifactChange: 'changed',
|
||||
isRemote: false,
|
||||
},
|
||||
{
|
||||
name: 'chart.png',
|
||||
type: 'png',
|
||||
artifactChange: 'generated',
|
||||
isRemote: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops malformed and duplicate artifact index rows', () => {
|
||||
expect(
|
||||
normalizeTaskArtifactFileList([
|
||||
{ filename: 'a.csv', path: '/tmp/a.csv', relativePath: 'a.csv' },
|
||||
{ filename: 'a.csv', path: '/tmp/other.csv', relativePath: 'a.csv' },
|
||||
{ filename: 'missing.txt' },
|
||||
])
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps an existing preview path while adding artifact change metadata', () => {
|
||||
const [file] = mergeFileInfoLists(
|
||||
[
|
||||
{
|
||||
name: 'report.md',
|
||||
path: 'http://localhost/files/stream?path=report.md',
|
||||
type: 'md',
|
||||
isRemote: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'report.md',
|
||||
path: '/Users/test/project/report.md',
|
||||
type: 'md',
|
||||
relativePath: 'report.md',
|
||||
artifactChange: 'changed',
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(file).toMatchObject({
|
||||
path: 'http://localhost/files/stream?path=report.md',
|
||||
relativePath: 'report.md',
|
||||
artifactChange: 'changed',
|
||||
isRemote: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts sandbox paths without treating the scheme suffix as a drive', () => {
|
||||
const files = extractFinalOutputFileList(
|
||||
'Created [CSV](sandbox:/Users/test/eigent/space_123/report.csv).'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue