diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index bf5f33992a0..953a28ec300 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -102,6 +102,11 @@ type WorkspaceOnboardingState = { onboardingCompletedAt?: string; }; +export type WorkspaceOnboardingStateSnapshot = { + bootstrapSeededAt?: string; + onboardingCompletedAt?: string; +}; + /** Set of recognized bootstrap filenames for runtime validation */ const VALID_BOOTSTRAP_NAMES: ReadonlySet = new Set([ DEFAULT_AGENTS_FILENAME, @@ -184,6 +189,24 @@ async function readWorkspaceOnboardingState(statePath: string): Promise { + const statePath = resolveWorkspaceStatePath(resolveUserPath(dir)); + const state = await readWorkspaceOnboardingState(statePath); + return { + bootstrapSeededAt: state.bootstrapSeededAt, + onboardingCompletedAt: state.onboardingCompletedAt, + }; +} + +export async function isWorkspaceOnboardingCompleted(dir: string): Promise { + const state = await readWorkspaceOnboardingStateForDir(dir); + return ( + typeof state.onboardingCompletedAt === "string" && state.onboardingCompletedAt.trim().length > 0 + ); +} + async function writeWorkspaceOnboardingState( statePath: string, state: WorkspaceOnboardingState, diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index bd5cc5cc3cc..c999f92def8 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({ fsAccess: vi.fn(async () => {}), fsMkdir: vi.fn(async () => undefined), fsAppendFile: vi.fn(async () => {}), + fsReadFile: vi.fn(async () => ""), + fsStat: vi.fn(async () => null), })); vi.mock("../../config/config.js", () => ({ @@ -81,6 +83,8 @@ vi.mock("node:fs/promises", async () => { access: mocks.fsAccess, mkdir: mocks.fsMkdir, appendFile: mocks.fsAppendFile, + readFile: mocks.fsReadFile, + stat: mocks.fsStat, }; return { ...patched, default: patched }; }); @@ -109,6 +113,21 @@ function makeCall(method: keyof typeof agentsHandlers, params: Record { + mocks.fsReadFile.mockImplementation(async () => { + throw createEnoentError(); + }); + mocks.fsStat.mockImplementation(async () => { + throw createEnoentError(); + }); +}); + /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ @@ -371,3 +390,37 @@ describe("agents.delete", () => { ); }); }); + +describe("agents.files.list", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfigReturn = {}; + }); + + it("includes BOOTSTRAP.md when onboarding has not completed", async () => { + const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); + await promise; + + const [, result] = respond.mock.calls[0] ?? []; + const files = (result as { files: Array<{ name: string }> }).files; + expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); + }); + + it("hides BOOTSTRAP.md when workspace onboarding is complete", async () => { + mocks.fsReadFile.mockImplementation(async (filePath: string | URL | number) => { + if (String(filePath).endsWith("workspace-state.json")) { + return JSON.stringify({ + onboardingCompletedAt: "2026-02-15T14:00:00.000Z", + }); + } + throw createEnoentError(); + }); + + const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); + await promise; + + const [, result] = respond.mock.calls[0] ?? []; + const files = (result as { files: Array<{ name: string }> }).files; + expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(false); + }); +}); diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index eb4262e43fd..f1a1dc986af 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -17,6 +17,7 @@ import { DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, ensureAgentWorkspace, + isWorkspaceOnboardingCompleted, } from "../../agents/workspace.js"; import { movePathToTrash } from "../../browser/trash.js"; import { @@ -52,6 +53,9 @@ const BOOTSTRAP_FILE_NAMES = [ DEFAULT_HEARTBEAT_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, ] as const; +const BOOTSTRAP_FILE_NAMES_WITHOUT_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter( + (name) => name !== DEFAULT_BOOTSTRAP_FILENAME, +); const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const; @@ -108,7 +112,7 @@ async function statFile(filePath: string): Promise { } } -async function listAgentFiles(workspaceDir: string) { +async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) { const files: Array<{ name: string; path: string; @@ -117,7 +121,10 @@ async function listAgentFiles(workspaceDir: string) { updatedAtMs?: number; }> = []; - for (const name of BOOTSTRAP_FILE_NAMES) { + const bootstrapFileNames = options?.hideBootstrap + ? BOOTSTRAP_FILE_NAMES_WITHOUT_ONBOARDING + : BOOTSTRAP_FILE_NAMES; + for (const name of bootstrapFileNames) { const filePath = path.join(workspaceDir, name); const meta = await statFile(filePath); if (meta) { @@ -417,7 +424,13 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const files = await listAgentFiles(workspaceDir); + let hideBootstrap = false; + try { + hideBootstrap = await isWorkspaceOnboardingCompleted(workspaceDir); + } catch { + // Fall back to showing BOOTSTRAP if workspace state cannot be read. + } + const files = await listAgentFiles(workspaceDir, { hideBootstrap }); respond(true, { agentId, workspace: workspaceDir, files }, undefined); }, "agents.files.get": async ({ params, respond }) => {