remove artifacts dir handling (#8945)

This commit is contained in:
Jack Amadeo
2026-05-04 09:15:02 -04:00
committed by GitHub
parent 5f9a5a53db
commit 5021a88ce3
22 changed files with 103 additions and 410 deletions
@@ -19,9 +19,6 @@
{
"path": "$HOME/.goose/**"
},
{
"path": "$HOME/.goose/artifacts/**"
},
{
"path": "$TEMP/**"
},
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$HOME/.goose/artifacts/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","app-test-driver:default","core:webview:allow-set-webview-zoom"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","app-test-driver:default","core:webview:allow-set-webview-zoom"]}}
@@ -61,8 +61,8 @@ mod tests {
#[test]
fn joins_absolute_path_and_subpath() {
assert_eq!(
resolve_path_parts(vec!["/tmp/project".to_string(), "artifacts".to_string()]),
Ok("/tmp/project/artifacts".to_string())
resolve_path_parts(vec!["/tmp/project".to_string(), "src".to_string()]),
Ok("/tmp/project/src".to_string())
);
}
@@ -81,24 +81,16 @@ mod tests {
};
assert_eq!(
resolve_path_parts(vec![
"~".to_string(),
".goose".to_string(),
"artifacts".to_string()
]),
Ok(home
.join(".goose")
.join("artifacts")
.to_string_lossy()
.into_owned())
resolve_path_parts(vec!["~".to_string()]),
Ok(home.to_string_lossy().into_owned())
);
assert_eq!(
resolve_path_parts(vec!["~/artifacts".to_string()]),
Ok(home.join("artifacts").to_string_lossy().into_owned())
resolve_path_parts(vec!["~/Documents".to_string()]),
Ok(home.join("Documents").to_string_lossy().into_owned())
);
assert_eq!(
resolve_path_parts(vec!["~\\artifacts".to_string()]),
Ok(home.join("artifacts").to_string_lossy().into_owned())
resolve_path_parts(vec!["~\\Documents".to_string()]),
Ok(home.join("Documents").to_string_lossy().into_owned())
);
}
+9 -24
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
fn projects_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
@@ -120,10 +120,6 @@ where
.collect())
}
fn project_artifacts_dir(project_dir: &Path) -> String {
project_dir.join("artifacts").to_string_lossy().into_owned()
}
#[derive(serde::Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct StoredProjectInfo {
@@ -170,10 +166,9 @@ pub struct ProjectInfo {
pub archived_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub artifacts_dir: String,
}
fn project_info_from_stored(project_dir: &Path, stored: StoredProjectInfo) -> ProjectInfo {
fn project_info_from_stored(stored: StoredProjectInfo) -> ProjectInfo {
ProjectInfo {
id: stored.id,
name: stored.name,
@@ -189,7 +184,6 @@ fn project_info_from_stored(project_dir: &Path, stored: StoredProjectInfo) -> Pr
archived_at: stored.archived_at,
created_at: stored.created_at,
updated_at: stored.updated_at,
artifacts_dir: project_artifacts_dir(project_dir),
}
}
@@ -223,7 +217,7 @@ pub fn list_projects() -> Result<Vec<ProjectInfo>, String> {
Err(_) => continue,
};
projects.push(project_info_from_stored(&path, info));
projects.push(project_info_from_stored(info));
}
projects.sort_by_key(|p| p.order);
@@ -253,7 +247,7 @@ pub fn list_archived_projects() -> Result<Vec<ProjectInfo>, String> {
let raw = fs::read_to_string(&project_json).unwrap_or_default();
if let Ok(info) = serde_json::from_str::<StoredProjectInfo>(&raw) {
if info.archived_at.is_some() {
projects.push(project_info_from_stored(&path, info));
projects.push(project_info_from_stored(info));
}
}
}
@@ -329,7 +323,7 @@ pub fn create_project(
.map_err(|e| format!("Failed to serialize project: {}", e))?;
fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?;
Ok(project_info_from_stored(&dir, stored))
Ok(project_info_from_stored(stored))
}
#[allow(clippy::too_many_arguments)]
@@ -374,7 +368,7 @@ pub fn update_project(
.map_err(|e| format!("Failed to serialize project: {}", e))?;
fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?;
Ok(project_info_from_stored(&dir, stored))
Ok(project_info_from_stored(stored))
}
#[tauri::command]
@@ -400,8 +394,8 @@ pub fn reorder_projects(order: Vec<(String, i32)>) -> Result<(), String> {
#[tauri::command]
pub fn get_project(id: String) -> Result<ProjectInfo, String> {
let (dir, info) = find_project_by_id(&id)?;
Ok(project_info_from_stored(&dir, info))
let (_, info) = find_project_by_id(&id)?;
Ok(project_info_from_stored(info))
}
#[tauri::command]
@@ -472,8 +466,7 @@ pub fn restore_project(id: String) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::{project_artifacts_dir, StoredProjectInfo};
use std::path::Path;
use super::StoredProjectInfo;
#[test]
fn deserializes_legacy_single_working_dir() {
@@ -497,12 +490,4 @@ mod tests {
assert_eq!(project.working_dirs, vec!["/tmp/legacy"]);
}
#[test]
fn builds_project_artifacts_dir_inside_project_storage() {
assert_eq!(
project_artifacts_dir(Path::new("/Users/test/.goose/projects/sample-project")),
"/Users/test/.goose/projects/sample-project/artifacts"
);
}
}
@@ -146,10 +146,7 @@ async fn wait_for_server_ready(port: u16, child: &mut Child) -> Result<(), Strin
}
fn default_serve_working_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".goose")
.join("artifacts")
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
}
fn reserve_free_port() -> Result<u16, String> {
@@ -83,49 +83,6 @@ function basenameOf(path: string): string {
return parts[parts.length - 1] ?? path;
}
function homeArtifactsRootFromRoots(roots: string[]): string | null {
return (
roots.find((root) => /\/\.goose\/artifacts\/?$/.test(root.trim())) ?? null
);
}
function stripRootArtifactsSegment(
path: string,
roots: string[],
): string | null {
for (const root of roots) {
const normalizedRoot = root.replace(/\/+$/, "");
const prefix = `${normalizedRoot}/artifacts/`;
if (path.startsWith(prefix)) {
return `${normalizedRoot}/${path.slice(prefix.length)}`;
}
}
return null;
}
function fallbackArtifactPath(path: string, roots: string[]): string | null {
const normalized = path.trim();
if (!/\/\.goose\/projects\/.+\/artifacts\/.+/.test(normalized)) {
const rootAnchored = stripRootArtifactsSegment(normalized, roots);
if (rootAnchored) {
return rootAnchored;
}
return null;
}
const filename = basenameOf(normalized);
if (!filename || filename === normalized) {
return null;
}
const homeArtifactsRoot = homeArtifactsRootFromRoots(roots);
if (!homeArtifactsRoot) {
return null;
}
return `${homeArtifactsRoot.replace(/\/+$/, "")}/${filename}`;
}
export function ArtifactPolicyProvider({
messages,
allowedRoots,
@@ -190,14 +147,9 @@ export function ArtifactPolicyProvider({
return path;
}
const fallbackPath = fallbackArtifactPath(path, normalizedRoots);
if (fallbackPath && (await pathExists(fallbackPath))) {
return fallbackPath;
}
return null;
},
[normalizedRoots],
[],
);
const checkPathExists = useCallback(
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Message } from "@/shared/types/messages";
import {
@@ -85,32 +85,6 @@ function ReadOnlyProbe({ readArgs }: { readArgs: Record<string, unknown> }) {
);
}
function FallbackProbe({
path = "/Users/test/.goose/projects/sample-project/artifacts/report.md",
}: {
path?: string;
}) {
const { pathExists, openResolvedPath } = useArtifactPolicyContext();
return (
<div>
<button
type="button"
onClick={async () => {
const exists = await pathExists(path);
(window as Window & { __artifactExists?: boolean }).__artifactExists =
exists;
}}
>
Check path
</button>
<button type="button" onClick={() => void openResolvedPath(path)}>
Open path
</button>
</div>
);
}
describe("ArtifactPolicyContext", () => {
it("computes one primary host per message and resolves tool cards by args identity", () => {
mockPathExists.mockReset();
@@ -163,7 +137,7 @@ describe("ArtifactPolicyContext", () => {
render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test/project-a", "/Users/test/.goose/artifacts"]}
allowedRoots={["/Users/test/project-a", "/Users/test"]}
>
<Probe
readArgs={readArgs}
@@ -215,7 +189,7 @@ describe("ArtifactPolicyContext", () => {
render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test/project-a", "/Users/test/.goose/artifacts"]}
allowedRoots={["/Users/test/project-a", "/Users/test"]}
>
<ReadOnlyProbe readArgs={readArgs} />
</ArtifactPolicyProvider>,
@@ -225,150 +199,6 @@ describe("ArtifactPolicyContext", () => {
expect(screen.getByTestId("read-only-artifacts")).toHaveTextContent("");
});
it("falls back to the home artifacts root when a project artifacts path is missing", async () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
mockPathExists.mockImplementation(
async (path: string) => path === "/Users/test/.goose/artifacts/report.md",
);
render(
<ArtifactPolicyProvider
messages={[]}
allowedRoots={[
"/Users/test/.goose/projects/sample-project/artifacts",
"/Users/test/.goose/artifacts",
]}
>
<FallbackProbe />
</ArtifactPolicyProvider>,
);
screen.getByRole("button", { name: "Check path" }).click();
await waitFor(() => {
expect(
(window as Window & { __artifactExists?: boolean }).__artifactExists,
).toBe(true);
});
screen.getByRole("button", { name: "Open path" }).click();
await waitFor(() => {
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
"/Users/test/.goose/artifacts/report.md",
);
});
});
it("falls back from a working-dir artifacts path to the project root when the file lives there", async () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
mockPathExists.mockImplementation(
async (path: string) =>
path === "/Users/test/project-a/README_ENHANCED.md",
);
render(
<ArtifactPolicyProvider
messages={[]}
allowedRoots={[
"/Users/test/project-a/artifacts",
"/Users/test/project-a",
"/Users/test/.goose/artifacts",
]}
>
<FallbackProbe path="/Users/test/project-a/artifacts/README_ENHANCED.md" />
</ArtifactPolicyProvider>,
);
screen.getByRole("button", { name: "Check path" }).click();
await waitFor(() => {
expect(
(window as Window & { __artifactExists?: boolean }).__artifactExists,
).toBe(true);
});
screen.getByRole("button", { name: "Open path" }).click();
await waitFor(() => {
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
"/Users/test/project-a/README_ENHANCED.md",
);
});
});
it("does not strip /artifacts/ from a parent directory in the path", async () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
// The file lives at the nested artifacts path — the parent `/artifacts/` should NOT be stripped
mockPathExists.mockImplementation(
async (path: string) =>
path === "/Users/test/artifacts/project/artifacts/README_ENHANCED.md",
);
render(
<ArtifactPolicyProvider
messages={[]}
allowedRoots={[
"/Users/test/artifacts/project/artifacts",
"/Users/test/artifacts/project",
"/Users/test/.goose/artifacts",
]}
>
<FallbackProbe path="/Users/test/artifacts/project/artifacts/README_ENHANCED.md" />
</ArtifactPolicyProvider>,
);
screen.getByRole("button", { name: "Check path" }).click();
await waitFor(() => {
expect(
(window as Window & { __artifactExists?: boolean }).__artifactExists,
).toBe(true);
});
screen.getByRole("button", { name: "Open path" }).click();
await waitFor(() => {
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
"/Users/test/artifacts/project/artifacts/README_ENHANCED.md",
);
});
});
it("falls back correctly when /artifacts/ appears in a parent dir", async () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
// File is NOT at the artifacts path, but IS at the root-stripped path
mockPathExists.mockImplementation(
async (path: string) =>
path === "/Users/test/artifacts/project/README.md",
);
render(
<ArtifactPolicyProvider
messages={[]}
allowedRoots={[
"/Users/test/artifacts/project/artifacts",
"/Users/test/artifacts/project",
"/Users/test/.goose/artifacts",
]}
>
<FallbackProbe path="/Users/test/artifacts/project/artifacts/README.md" />
</ArtifactPolicyProvider>,
);
screen.getByRole("button", { name: "Check path" }).click();
await waitFor(() => {
expect(
(window as Window & { __artifactExists?: boolean }).__artifactExists,
).toBe(true);
});
screen.getByRole("button", { name: "Open path" }).click();
await waitFor(() => {
expect(vi.mocked(openPath)).toHaveBeenCalledWith(
"/Users/test/artifacts/project/README.md",
);
});
});
it("uses assistant text after a tool call to populate file actions and the Files tab", () => {
mockPathExists.mockReset();
vi.mocked(openPath).mockReset();
@@ -396,7 +226,7 @@ describe("ArtifactPolicyContext", () => {
},
{
type: "text",
text: "The file alpha.md has been created at /Users/test/.goose/artifacts/alpha.md.",
text: "The file alpha.md has been created at /Users/test/alpha.md.",
},
],
},
@@ -405,7 +235,7 @@ describe("ArtifactPolicyContext", () => {
render(
<ArtifactPolicyProvider
messages={messages}
allowedRoots={["/Users/test/.goose/artifacts"]}
allowedRoots={["/Users/test"]}
>
<TextFollowupProbe writeArgs={writeArgs} />
</ArtifactPolicyProvider>,
@@ -415,10 +245,10 @@ describe("ArtifactPolicyContext", () => {
"primary_host",
);
expect(screen.getByTestId("text-followup-path")).toHaveTextContent(
"/Users/test/.goose/artifacts/alpha.md",
"/Users/test/alpha.md",
);
expect(screen.getByTestId("text-followup-artifacts")).toHaveTextContent(
"/Users/test/.goose/artifacts/alpha.md",
"/Users/test/alpha.md",
);
});
});
@@ -97,7 +97,7 @@ describe("useArtifactLinkHandler", () => {
const user = userEvent.setup();
const blocked = makeCandidate({
allowed: false,
blockedReason: "Path is outside allowed project/artifacts roots.",
blockedReason: "Path is outside allowed roots.",
});
mockResolveMarkdownHref.mockReturnValue(blocked);
@@ -106,7 +106,7 @@ describe("useArtifactLinkHandler", () => {
expect(mockOpenResolvedPath).not.toHaveBeenCalled();
expect(screen.getByTestId("notice")).toHaveTextContent(
"Path is outside allowed project/artifacts roots.",
"Path is outside allowed roots.",
);
});
@@ -142,7 +142,7 @@ describe("useArtifactLinkHandler", () => {
await user.click(screen.getByText("Blocked"));
expect(screen.getByTestId("notice")).toHaveTextContent(
"Path is outside allowed project/artifacts roots.",
"Path is outside allowed roots.",
);
});
});
@@ -30,8 +30,7 @@ export function useArtifactLinkHandler() {
if (!resolved.allowed) {
setPathNotice(
resolved.blockedReason ||
"Path is outside allowed project/artifacts roots.",
resolved.blockedReason || "Path is outside allowed roots.",
);
return;
}
@@ -8,11 +8,7 @@ import {
resolvePathCandidate,
} from "../artifactPathPolicy";
const roots = [
"/Users/test/project-a",
"/Users/test/project-b",
"/Users/test/.goose/artifacts",
];
const roots = ["/Users/test/project-a", "/Users/test/project-b", "/Users/test"];
describe("artifactPathPolicy", () => {
it("prefers the latest write-oriented tool call over earlier tool calls", () => {
@@ -112,7 +108,7 @@ describe("artifactPathPolicy", () => {
expect(allowed.allowed).toBe(true);
expect(allowed.blockedReason).toBeNull();
const blocked = evaluatePathScope("/Users/test/outside/file.md", roots);
const blocked = evaluatePathScope("/Users/other/outside/file.md", roots);
expect(blocked.allowed).toBe(false);
expect(blocked.blockedReason).toContain("outside allowed");
});
@@ -128,7 +124,7 @@ describe("artifactPathPolicy", () => {
toolCallIndex: 0,
},
],
["/Users/test/.goose/artifacts"],
["/Users/test"],
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
@@ -146,7 +142,7 @@ describe("artifactPathPolicy", () => {
args: { path: "/Users/test/Desktop/coffee_shop_inventory.csv" },
toolCallIndex: 0,
},
["/Users/test/.goose/artifacts"],
["/Users/test/project-a"],
);
expect(candidates).toHaveLength(1);
@@ -268,7 +264,7 @@ describe("artifactPathPolicy", () => {
toolCallIndex: 0,
},
],
["/Users/test", "/Users/test/.goose/artifacts"],
["/Users/test", "/Users/test"],
);
expect(ranking.primaryCandidate?.resolvedPath).toBe(
@@ -301,18 +297,18 @@ describe("artifactPathPolicy", () => {
},
{
type: "text",
text: "The file alpha.md has been created at /Users/test/.goose/artifacts/alpha.md.",
text: "The file alpha.md has been created at /Users/test/alpha.md.",
},
],
},
],
["/Users/test/.goose/artifacts"],
["/Users/test"],
);
const ranking = result.byMessageId.get("assistant-1");
expect(ranking?.primaryToolCallId).toBe("tool-1");
expect(ranking?.primaryCandidate?.resolvedPath).toBe(
"/Users/test/.goose/artifacts/alpha.md",
"/Users/test/alpha.md",
);
expect(ranking?.primaryCandidate?.allowed).toBe(true);
});
@@ -367,7 +363,7 @@ describe("artifactPathPolicy", () => {
toolCallIndex: 0,
},
],
["/Users/test/.goose/artifacts"],
["/Users/test"],
);
expect(ranking.primaryCandidate?.allowed).toBe(true);
@@ -382,10 +378,7 @@ describe("artifactPathPolicy", () => {
});
it("preserves Windows drive roots when resolving relative paths", () => {
const windowsRoots = [
"C:/Users/test/project-a",
"C:/Users/test/.goose/artifacts",
];
const windowsRoots = ["C:/Users/test/project-a", "C:/Users/test"];
const resolved = resolvePathCandidate(
"output/final_report.md",
windowsRoots,
@@ -245,11 +245,7 @@ function pickBaseRoot(allowedRoots: string[]): string | null {
const normalizedRoots = allowedRoots
.map((root) => normalizePath(root))
.filter(Boolean);
if (normalizedRoots.length === 0) return null;
const projectRoots = normalizedRoots.filter(
(root) => !root.includes("/.goose/artifacts"),
);
return projectRoots[0] ?? normalizedRoots[0];
return normalizedRoots[0] ?? null;
}
export function resolvePathCandidate(
@@ -294,7 +290,7 @@ export function evaluatePathScope(
}
return {
allowed: false,
blockedReason: "Path is outside allowed project/artifacts roots.",
blockedReason: "Path is outside allowed roots.",
};
}
@@ -167,7 +167,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
it("disables button and shows blocked reason for disallowed primary candidate", () => {
const blocked = makeCandidate({
allowed: false,
blockedReason: "Path is outside allowed project/artifacts roots.",
blockedReason: "Path is outside allowed roots.",
});
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
@@ -179,7 +179,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
expect(screen.getByRole("button", { name: /open file/i })).toBeDisabled();
expect(
screen.getByText("Path is outside allowed project/artifacts roots."),
screen.getByText("Path is outside allowed roots."),
).toBeInTheDocument();
});
@@ -191,7 +191,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
rawPath: "/outside/secret.md",
resolvedPath: "/Users/test/outside/secret.md",
allowed: false,
blockedReason: "Path is outside allowed project/artifacts roots.",
blockedReason: "Path is outside allowed roots.",
});
mockResolveToolCardDisplay.mockReturnValue({
role: "primary_host",
@@ -205,7 +205,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
const secondaryBtn = screen.getByTitle(blockedSecondary.resolvedPath);
expect(secondaryBtn).toBeDisabled();
expect(
screen.getByText("Path is outside allowed project/artifacts roots."),
screen.getByText("Path is outside allowed roots."),
).toBeInTheDocument();
});
@@ -15,7 +15,6 @@ export interface ProjectInfo {
archivedAt: string | null;
createdAt: string;
updatedAt: string;
artifactsDir: string;
}
export interface ProjectIconCandidate {
@@ -19,7 +19,6 @@ describe("chatProjectContext", () => {
preferredProvider: "goose",
preferredModel: "claude-sonnet-4",
workingDirs: ["/Users/wesb/dev/goose2"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
useWorktrees: true,
order: 0,
archivedAt: null,
@@ -33,7 +32,7 @@ describe("chatProjectContext", () => {
"Working directories: /Users/wesb/dev/goose2",
);
expect(systemPrompt).toContain(
"Artifact directory: /Users/wesb/dev/goose2/artifacts",
"Default working directory: /Users/wesb/dev/goose2",
);
expect(systemPrompt).toContain("Preferred provider: goose");
expect(systemPrompt).toContain(
@@ -41,7 +40,7 @@ describe("chatProjectContext", () => {
);
expect(systemPrompt).toContain("<project-file-policy>");
expect(systemPrompt).toContain(
"Write newly generated files to /Users/wesb/dev/goose2/artifacts by default.",
"Use /Users/wesb/dev/goose2 as the default working directory for this project.",
);
expect(systemPrompt).toContain("<project-instructions>");
expect(systemPrompt).toContain("Always read AGENTS.md before editing.");
@@ -62,18 +61,17 @@ describe("chatProjectContext", () => {
expect(
getProjectFolderOption({
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
}),
).toEqual([
{
id: "/Users/wesb/dev/goose2/artifacts",
name: "artifacts",
path: "/Users/wesb/dev/goose2/artifacts",
id: "/Users/wesb/dev/goose2",
name: "goose2",
path: "/Users/wesb/dev/goose2",
},
{
id: "/Users/wesb/dev/other/artifacts",
name: "artifacts",
path: "/Users/wesb/dev/other/artifacts",
id: "/Users/wesb/dev/other",
name: "other",
path: "/Users/wesb/dev/other",
},
]);
});
@@ -82,30 +80,19 @@ describe("chatProjectContext", () => {
expect(
getProjectFolderOption({
workingDirs: [],
artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts",
}),
).toEqual([
{
id: "/Users/wesb/.goose/projects/sample-project/artifacts",
name: "artifacts",
path: "/Users/wesb/.goose/projects/sample-project/artifacts",
},
]);
).toEqual([]);
});
it("returns an empty array when project is null", () => {
expect(getProjectFolderOption(null)).toEqual([]);
});
it("returns only artifact subdirectories for working dirs", () => {
it("returns working dirs unchanged", () => {
expect(
getProjectArtifactRoots({
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
}),
).toEqual([
"/Users/wesb/dev/goose2/artifacts",
"/Users/wesb/dev/other/artifacts",
]);
).toEqual(["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"]);
});
});
@@ -21,53 +21,35 @@ export function getProjectFolderName(path: string): string {
return parts[parts.length - 1] ?? normalized;
}
function appendArtifactsSegment(path: string): string {
return `${path.replace(/[\\/]+$/, "")}/artifacts`;
}
function resolveProjectArtifactRoots(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
function resolveProjectRoots(
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
): string[] {
const workingDirs = (project?.workingDirs ?? [])
return (project?.workingDirs ?? [])
.map((directory) => trimValue(directory))
.filter((directory): directory is string => directory !== null);
if (workingDirs.length > 0) {
return workingDirs.map(appendArtifactsSegment);
}
const artifactsDir = trimValue(project?.artifactsDir);
return artifactsDir ? [artifactsDir] : [];
}
export function getProjectArtifactRoots(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
): string[] {
return resolveProjectArtifactRoots(project);
return resolveProjectRoots(project);
}
export function resolveProjectDefaultArtifactRoot(
project: ProjectInfo | null | undefined,
): string | undefined {
const workingDirs = (project?.workingDirs ?? [])
.map((directory) => trimValue(directory))
.filter((directory): directory is string => directory !== null);
if (workingDirs.length > 0) {
return appendArtifactsSegment(workingDirs[0]);
}
return trimValue(project?.artifactsDir) ?? undefined;
const workingDirs = resolveProjectRoots(project);
return workingDirs[0];
}
export async function defaultGlobalArtifactRoot(): Promise<string> {
return (await resolvePath({ parts: ["~", ".goose", "artifacts"] })).path;
return (await resolvePath({ parts: ["~"] })).path;
}
export function getProjectFolderOption(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
): ProjectFolderOption[] {
return resolveProjectArtifactRoots(project).map((d) => ({
return resolveProjectRoots(project).map((d) => ({
id: d,
name: getProjectFolderName(d),
path: d,
@@ -81,12 +63,10 @@ export function buildProjectSystemPrompt(
return undefined;
}
const artifactDir = resolveProjectDefaultArtifactRoot(project);
const workingDir = resolveProjectDefaultArtifactRoot(project);
const settings: string[] = [`Project name: ${project.name}`];
const description = trimValue(project.description);
const workingDirs = (project.workingDirs ?? [])
.map((d) => trimValue(d))
.filter((d): d is string => d !== null);
const workingDirs = resolveProjectRoots(project);
const prompt = trimValue(project.prompt);
if (description) {
@@ -95,8 +75,8 @@ export function buildProjectSystemPrompt(
if (workingDirs.length > 0) {
settings.push(`Working directories: ${workingDirs.join(", ")}`);
}
if (artifactDir) {
settings.push(`Artifact directory: ${artifactDir}`);
if (workingDir) {
settings.push(`Default working directory: ${workingDir}`);
}
if (project.preferredProvider) {
settings.push(`Preferred provider: ${project.preferredProvider}`);
@@ -114,13 +94,12 @@ export function buildProjectSystemPrompt(
`<project-settings>\n${settings.join("\n")}\n</project-settings>`,
];
if (artifactDir) {
if (workingDir) {
sections.push(
`<project-file-policy>\n` +
`Write newly generated files to ${artifactDir} by default.\n` +
`When creating translations, variants, summaries, or derived documents from existing project files, save the new file in ${artifactDir} instead of the project root.\n` +
`Only write outside ${artifactDir} when the user explicitly asks you to edit or create a file at a specific path.\n` +
`If you need to read existing files elsewhere in the project, that is fine, but generated outputs should stay in ${artifactDir} unless the user says otherwise.\n` +
`Use ${workingDir} as the default working directory for this project.\n` +
`Write newly generated files relative to ${workingDir} by default.\n` +
`Only write outside ${workingDir} when the user explicitly asks you to edit or create a file at a specific path.\n` +
`</project-file-policy>`,
);
}
@@ -27,7 +27,6 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
artifactsDir: "",
...overrides,
};
}
@@ -37,70 +36,64 @@ describe("sessionCwdSelection", () => {
vi.mocked(resolvePath).mockReset();
});
it("resolves the first workspace root to the default artifact root", () => {
it("resolves the first workspace root unchanged", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
}),
),
).toBe("/Users/wesb/dev/goose2/artifacts");
).toBe("/Users/wesb/dev/goose2");
});
it("falls back to the stored project artifact root when no workspace roots exist", () => {
it("returns undefined when no workspace roots exist", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: [],
artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts",
}),
),
).toBe("/Users/wesb/.goose/projects/sample-project/artifacts");
});
it("returns undefined for a pathless project artifact root", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: [],
artifactsDir: " ",
}),
),
).toBeUndefined();
});
it("falls back to global artifacts for a pathless project session cwd", async () => {
it("returns undefined for a pathless project fallback directory", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: [],
}),
),
).toBeUndefined();
});
it("falls back to home for a pathless project session cwd", async () => {
vi.mocked(resolvePath).mockResolvedValue({
path: "/Users/wesb/.goose/artifacts",
path: "/Users/wesb",
});
await expect(
resolveSessionCwd(
makeProject({
workingDirs: [],
artifactsDir: " ",
}),
),
).resolves.toBe("/Users/wesb/.goose/artifacts");
).resolves.toBe("/Users/wesb");
expect(resolvePath).toHaveBeenCalledWith({
parts: ["~", ".goose", "artifacts"],
parts: ["~"],
});
});
describe("defaultGlobalArtifactRoot", () => {
it("resolves the global artifact root through the path resolver", async () => {
it("resolves the home directory through the path resolver", async () => {
vi.mocked(resolvePath).mockResolvedValue({
path: "/Users/wesb/.goose/artifacts",
path: "/Users/wesb",
});
await expect(defaultGlobalArtifactRoot()).resolves.toBe(
"/Users/wesb/.goose/artifacts",
);
await expect(defaultGlobalArtifactRoot()).resolves.toBe("/Users/wesb");
expect(resolvePath).toHaveBeenCalledWith({
parts: ["~", ".goose", "artifacts"],
parts: ["~"],
});
});
});
@@ -19,15 +19,10 @@ function buildSessionCwdParts(
.map((directory) => trimValue(directory))
.filter((directory): directory is string => directory !== null);
if (workingDirs.length > 0) {
return [workingDirs[0], "artifacts"];
return [workingDirs[0]];
}
const artifactRoot = trimValue(project?.artifactsDir);
if (artifactRoot) {
return [artifactRoot];
}
return ["~", ".goose", "artifacts"];
return ["~"];
}
export async function resolveSessionCwd(
@@ -106,7 +106,6 @@ function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
archivedAt: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
artifactsDir: "/home/user/code/.goose",
...overrides,
};
}
@@ -53,7 +53,7 @@ describe("acpNotificationHandler", () => {
"local-session",
"goose-session",
"goose",
"/Users/aharvard/.goose/artifacts",
"/Users/aharvard",
);
setActiveMessageId("goose-session", "assistant-1");
+1 -1
View File
@@ -226,7 +226,7 @@ export async function acpLoadSession(
gooseSessionId: string,
workingDir?: string,
): Promise<void> {
const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts";
const effectiveWorkingDir = workingDir ?? "~";
const sid = sessionId.slice(0, 8);
const t0 = performance.now();
const rollbackSessionRegistration = sessionTracker.registerSession(
+1 -1
View File
@@ -98,7 +98,7 @@ export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
const client = await getClient();
const response = await client.unstable_forkSession({
sessionId,
cwd: "~/.goose/artifacts",
cwd: "~",
});
return {
sessionId: response.sessionId,
@@ -197,7 +197,7 @@
"openFile": "Open file",
"openFolder": "Open folder",
"openPath": "Open path",
"pathOutsideRoots": "Path is outside allowed project/artifacts roots.",
"pathOutsideRoots": "Path is outside allowed roots",
"structuredContent": "Structured content",
"structuredOutput": "Structured output",
"structuredOutputLines": "{{count}} lines"