From 87d49abd87778519827738d530f615ac0e609bef Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 19:21:43 +0200 Subject: [PATCH] fix: stabilize sf startup and state linting --- .../extension-ui-controller.test.ts | 52 ++++ .../controllers/extension-ui-controller.ts | 39 ++- .../controllers/input-controller.test.ts | 65 +++- .../controllers/input-controller.ts | 13 +- .../src/modes/interactive/interactive-mode.ts | 294 +++++++++++++++++- src/resource-loader.ts | 4 + .../extensions/sf/bootstrap/db-tools.js | 21 +- .../extensions/sf/bootstrap/register-hooks.js | 26 ++ src/resources/extensions/sf/doctor.js | 140 ++++++++- .../extensions/sf/prompts/plan-milestone.md | 7 +- .../extensions/sf/schedule/schedule-types.js | 19 +- .../sf/tests/doctor-sf-form-lint.test.mjs | 82 +++++ .../sf/tests/schedule-kinds.test.mjs | 109 +++++++ .../sf/tests/schedule-milestone.test.mjs | 232 ++++++++++++++ .../sf/tests/schedule-store.test.mjs | 2 + src/tests/resource-loader.test.ts | 40 +++ 16 files changed, 1111 insertions(+), 34 deletions(-) create mode 100644 src/resources/extensions/sf/tests/doctor-sf-form-lint.test.mjs create mode 100644 src/resources/extensions/sf/tests/schedule-kinds.test.mjs create mode 100644 src/resources/extensions/sf/tests/schedule-milestone.test.mjs diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts index 249f0ca04..4b06c5e83 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.test.ts @@ -51,3 +51,55 @@ test("notify_when_extension_notify_missing_routes_info_and_success_to_status", ( ["Done", { append: true }], ]); }); + +test("set_widget_when_host_supports_widgets_uses_dedicated_handler", () => { + const calls: unknown[][] = []; + const ui = createExtensionUIContext({ + setExtensionWidget(key: string, content: unknown, options?: unknown) { + calls.push([key, content, options]); + }, + }); + + const content = ["Ready"]; + const options = { placement: "belowEditor" as const }; + ui.setWidget("sf-notifications", content, options); + + assert.deepEqual(calls, [["sf-notifications", content, options]]); +}); + +test("set_widget_when_widget_host_missing_routes_string_content_to_status", () => { + const statuses: unknown[][] = []; + const ui = createExtensionUIContext({ + showStatus(message: string, options?: unknown) { + statuses.push([message, options]); + }, + }); + + ui.setWidget("sf-notifications", ["Ready", "Next"], { + placement: "belowEditor", + }); + + assert.deepEqual(statuses, [["Ready\nNext", { append: false }]]); +}); + +test("set_widget_when_widget_host_missing_ignores_factory_without_throwing", () => { + let renderRequested = false; + const ui = createExtensionUIContext({ + ui: { + requestRender() { + renderRequested = true; + }, + }, + }); + + ui.setWidget( + "sf-notifications", + () => ({ + render: () => [], + invalidate: () => {}, + }), + { placement: "belowEditor" }, + ); + + assert.equal(renderRequested, true); +}); diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts index b438b10e5..f66e19780 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts @@ -1,4 +1,7 @@ -import type { ExtensionUIContext } from "../../../core/extensions/index.js"; +import type { + ExtensionUIContext, + ExtensionWidgetOptions, +} from "../../../core/extensions/index.js"; import { appKey } from "../components/keybinding-hints.js"; import { getAvailableThemesWithPaths, @@ -11,6 +14,11 @@ import { type ExtensionNotifyType = "info" | "warning" | "error" | "success"; +type ExtensionWidgetContent = + | string[] + | ((...args: any[]) => unknown) + | undefined; + function notifyHost( host: any, message: string, @@ -36,6 +44,33 @@ function notifyHost( host.ui?.requestRender?.(); } +function setWidgetHost( + host: any, + key: string, + content: ExtensionWidgetContent, + options?: ExtensionWidgetOptions, +): void { + if (typeof host.setExtensionWidget === "function") { + host.setExtensionWidget(key, content, options); + return; + } + + if (content === undefined) { + host.ui?.requestRender?.(); + return; + } + + if (Array.isArray(content) && typeof host.showStatus === "function") { + const message = content.filter(Boolean).join("\n"); + if (message) { + host.showStatus(message, { append: false }); + } + return; + } + + host.ui?.requestRender?.(); +} + export function createExtensionUIContext(host: any): ExtensionUIContext { return { select: (title, options, opts) => @@ -67,7 +102,7 @@ export function createExtensionUIContext(host: any): ExtensionUIContext { } }, setWidget: (key, content, options) => - host.setExtensionWidget(key, content, options), + setWidgetHost(host, key, content, options), setFooter: (factory) => host.setExtensionFooter(factory), setHeader: (factory) => host.setExtensionHeader(factory), setTitle: (title) => host.ui.terminal.setTitle(title), diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts index 020ad2aab..09c64e9c4 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts @@ -5,6 +5,7 @@ import { setupEditorSubmitHandler } from "./input-controller.js"; type HostOptions = { knownSlashCommands?: string[]; + slashCommandContext?: boolean; }; function getSlashCommandName(text: string): string { @@ -62,16 +63,20 @@ function createHost(options: HostOptions = {}) { renderRequests += 1; }, }, - getSlashCommandContext: () => ({ - session: host.session, - showSettingsSelector: () => { - settingsOpened += 1; - }, - showStatus: host.showStatus, - shutdown: async () => { - shutdowns += 1; - }, - }), + ...(options.slashCommandContext === false + ? {} + : { + getSlashCommandContext: () => ({ + session: host.session, + showSettingsSelector: () => { + settingsOpened += 1; + }, + showStatus: host.showStatus, + shutdown: async () => { + shutdowns += 1; + }, + }), + }), handleBashCommand: async () => {}, showWarning(message: string) { warnings.push(message); @@ -93,7 +98,6 @@ function createHost(options: HostOptions = {}) { updatePendingMessagesDisplay() { pendingDisplayUpdates += 1; }, - flushPendingBashComponents() {}, contextualTips: { evaluate: () => undefined, recordBashIncluded() {}, @@ -211,6 +215,45 @@ test("input-controller: prompt template slash commands fall through to session.p assert.deepEqual(errors, []); }); +test("input-controller: known extension slash command falls through when slash context is absent", async () => { + const { host, prompted, errors, history } = createHost({ + knownSlashCommands: ["sf"], + slashCommandContext: false, + }); + + await host.defaultEditor.onSubmit("/sf next"); + + assert.deepEqual(prompted, ["/sf next"]); + assert.deepEqual(errors, []); + assert.deepEqual(history, ["/sf next"]); +}); + +test("input-controller: built-in slash command does not crash when slash context is absent", async () => { + const { host, prompted, errors, getSettingsOpened, getEditorText } = + createHost({ + slashCommandContext: false, + }); + + await host.defaultEditor.onSubmit("/settings"); + + assert.equal(getSettingsOpened(), 0); + assert.deepEqual(prompted, []); + assert.deepEqual(errors, [ + "Unknown command: /settings. Use slash autocomplete to see available commands.", + ]); + assert.equal(getEditorText(), ""); +}); + +test("input-controller: normal prompt submit does not require stale bash component flush hook", async () => { + const { host, prompted, errors, history } = createHost(); + + await host.defaultEditor.onSubmit("ping"); + + assert.deepEqual(prompted, ["ping"]); + assert.deepEqual(errors, []); + assert.deepEqual(history, ["ping"]); +}); + test("input-controller: skill slash commands fall through to session.prompt", async () => { const { host, prompted, errors } = createHost({ knownSlashCommands: ["skill:create-skill"], diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts index e335def7f..1b9263eb0 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -4,7 +4,7 @@ import { dispatchSlashCommand } from "../slash-command-handlers.js"; export function setupEditorSubmitHandler( host: InteractiveModeStateHost & { - getSlashCommandContext: () => any; + getSlashCommandContext?: () => any; handleBashCommand: ( command: string, excludeFromContext?: boolean, @@ -17,7 +17,6 @@ export function setupEditorSubmitHandler( isKnownSlashCommand: (text: string) => boolean; queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void; updatePendingMessagesDisplay: () => void; - flushPendingBashComponents: () => void; contextualTips: ContextualTips; getContextPercent: () => number | undefined; options?: { submitPromptsDirectly?: boolean }; @@ -28,10 +27,10 @@ export function setupEditorSubmitHandler( if (!text) return; if (text.startsWith("/") && !looksLikeFilePath(text)) { - const handled = await dispatchSlashCommand( - text, - host.getSlashCommandContext(), - ); + const slashContext = host.getSlashCommandContext?.(); + const handled = slashContext + ? await dispatchSlashCommand(text, slashContext) + : false; if (handled) { host.editor.setText(""); return; @@ -114,8 +113,6 @@ export function setupEditorSubmitHandler( return; } - host.flushPendingBashComponents(); - if (host.onInputCallback) { host.onInputCallback(text); host.editor.addToHistory?.(text); diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 664063225..78f60e184 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -107,6 +107,7 @@ import { providerDisplayName, } from "./components/model-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; +import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; @@ -116,8 +117,12 @@ import { handleAgentEvent } from "./controllers/chat-controller.js"; import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js"; import { setupEditorSubmitHandler as setupEditorSubmitHandlerController } from "./controllers/input-controller.js"; import { updateAvailableProviderCount as updateAvailableProviderCountController } from "./controllers/model-controller.js"; -import { getAppKeyDisplay } from "./slash-command-handlers.js"; import { + getAppKeyDisplay, + type SlashCommandContext, +} from "./slash-command-handlers.js"; +import { + getAvailableThemes, getEditorTheme, getMarkdownTheme, initTheme, @@ -233,6 +238,8 @@ export class InteractiveMode { // Track if editor is in bash mode (text starts with !) private isBashMode = false; + private bashComponent: BashExecutionComponent | undefined = undefined; + private pendingBashComponents: BashExecutionComponent[] = []; // Contextual tips — session-scoped, non-intrusive hints private contextualTips = new ContextualTips(); @@ -2104,6 +2111,203 @@ export class InteractiveMode { } } + getSlashCommandContext(): SlashCommandContext { + return { + session: this.session, + ui: this.ui, + keybindings: this.keybindings, + chatContainer: this.chatContainer, + statusContainer: this.statusContainer, + editorContainer: this.editorContainer, + headerContainer: this.headerContainer, + pendingMessagesContainer: this.pendingMessagesContainer, + editor: this.editor, + defaultEditor: this.defaultEditor, + sessionManager: this.sessionManager, + settingsManager: this.settingsManager, + invalidateFooter: () => this.footer.invalidate(), + showStatus: (message) => this.showStatus(message), + showError: (message) => this.showError(message), + showWarning: (message) => this.showWarning(message), + showSelector: (create) => this.showSelector(create), + updateEditorBorderColor: () => this.updateEditorBorderColor(), + getMarkdownThemeWithSettings: () => this.getMarkdownThemeWithSettings(), + requestRender: () => this.ui.requestRender(), + updateTerminalTitle: () => this.updateTerminalTitle(), + showSettingsSelector: () => this.showSettingsSelector(), + showModelsSelector: async () => this.showModelSelector(), + handleModelCommand: async (searchTerm) => + this.showModelSelector(searchTerm), + showUserMessageSelector: () => this.showUserMessageSelector(), + showTreeSelector: () => this.showTreeSelector(), + showProviderManager: () => + this.showError("Provider manager is unavailable in this build."), + showOAuthSelector: async () => + this.showError("OAuth selector is unavailable in this build."), + showSessionSelector: () => this.showSessionSelector(), + handleClearCommand: () => this.handleClearCommand(), + handleReloadCommand: () => this.handleReloadCommand(), + handleDebugCommand: () => this.handleDebugCommand(), + shutdown: () => this.shutdown(), + executeCompaction: (instructions, isAuto) => + this.executeCompaction(instructions, isAuto), + handleBashCommand: (command, options) => + this.handleBashCommand( + command, + options?.excludeFromContext, + options?.displayCommand, + options?.loginShell, + ), + }; + } + + private showSettingsSelector(): void { + this.showSelector((done) => { + const selector = new SettingsSelectorComponent( + { + autoCompact: this.session.autoCompactionEnabled, + showImages: this.settingsManager.getShowImages(), + autoResizeImages: this.settingsManager.getImageAutoResize(), + blockImages: this.settingsManager.getBlockImages(), + enableSkillCommands: this.settingsManager.getEnableSkillCommands(), + steeringMode: this.session.steeringMode, + followUpMode: this.session.followUpMode, + transport: this.settingsManager.getTransport(), + thinkingLevel: this.session.thinkingLevel, + availableThinkingLevels: this.session.getAvailableThinkingLevels(), + currentTheme: this.settingsManager.getTheme() || "dark", + availableThemes: getAvailableThemes(), + hideThinkingBlock: this.hideThinkingBlock, + collapseChangelog: this.settingsManager.getCollapseChangelog(), + doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), + treeFilterMode: this.settingsManager.getTreeFilterMode(), + showHardwareCursor: this.settingsManager.getShowHardwareCursor(), + editorPaddingX: this.settingsManager.getEditorPaddingX(), + autocompleteMaxVisible: + this.settingsManager.getAutocompleteMaxVisible(), + respectGitignoreInPicker: + this.settingsManager.getRespectGitignoreInPicker(), + quietStartup: this.settingsManager.getQuietStartup(), + clearOnShrink: this.settingsManager.getClearOnShrink(), + timestampFormat: this.settingsManager.getTimestampFormat(), + proxyFamilyProviders: {}, + }, + { + onAutoCompactChange: (enabled) => { + this.session.setAutoCompactionEnabled(enabled); + this.footer.setAutoCompactEnabled(enabled); + }, + onShowImagesChange: (enabled) => { + this.settingsManager.setShowImages(enabled); + for (const child of this.chatContainer.children) { + if (child instanceof ToolExecutionComponent) { + child.setShowImages(enabled); + } + } + }, + onAutoResizeImagesChange: (enabled) => { + this.settingsManager.setImageAutoResize(enabled); + }, + onBlockImagesChange: (blocked) => { + this.settingsManager.setBlockImages(blocked); + }, + onEnableSkillCommandsChange: (enabled) => { + this.settingsManager.setEnableSkillCommands(enabled); + this.setupAutocomplete(); + }, + onSteeringModeChange: (mode) => { + this.session.setSteeringMode(mode); + }, + onFollowUpModeChange: (mode) => { + this.session.setFollowUpMode(mode); + }, + onTransportChange: (transport) => { + this.settingsManager.setTransport(transport); + this.session.agent.setTransport(transport); + }, + onThinkingLevelChange: (level) => { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + }, + onThemeChange: (themeName) => { + const result = setTheme(themeName, true); + this.settingsManager.setTheme(themeName); + this.ui.invalidate(); + if (!result.success) { + this.showError( + `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`, + ); + } + }, + onThemePreview: (themeName) => { + const result = setTheme(themeName, true); + if (result.success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + }, + onHideThinkingBlockChange: (hidden) => { + this.hideThinkingBlock = hidden; + this.settingsManager.setHideThinkingBlock(hidden); + this.rebuildChatFromMessages(); + }, + onCollapseChangelogChange: (collapsed) => { + this.settingsManager.setCollapseChangelog(collapsed); + }, + onDoubleEscapeActionChange: (action) => { + this.settingsManager.setDoubleEscapeAction(action); + }, + onTreeFilterModeChange: (mode) => { + this.settingsManager.setTreeFilterMode(mode); + }, + onShowHardwareCursorChange: (enabled) => { + this.settingsManager.setShowHardwareCursor(enabled); + this.ui.setShowHardwareCursor(enabled); + }, + onEditorPaddingXChange: (padding) => { + this.settingsManager.setEditorPaddingX(padding); + this.defaultEditor.setPaddingX(padding); + if (this.editor !== this.defaultEditor) { + this.editor.setPaddingX?.(padding); + } + }, + onAutocompleteMaxVisibleChange: (maxVisible) => { + this.settingsManager.setAutocompleteMaxVisible(maxVisible); + this.defaultEditor.setAutocompleteMaxVisible(maxVisible); + if (this.editor !== this.defaultEditor) { + this.editor.setAutocompleteMaxVisible?.(maxVisible); + } + }, + onRespectGitignoreInPickerChange: (enabled) => { + this.settingsManager.setRespectGitignoreInPicker(enabled); + this.autocompleteProvider?.setRespectGitignore(enabled); + }, + onQuietStartupChange: (enabled) => { + this.settingsManager.setQuietStartup(enabled); + }, + onClearOnShrinkChange: (enabled) => { + this.settingsManager.setClearOnShrink(enabled); + this.ui.setClearOnShrink(enabled); + }, + onTimestampFormatChange: (format) => { + this.settingsManager.setTimestampFormat(format); + }, + onProxyFamilyProviderChange: (familyPrefix, provider) => { + this.settingsManager.setProxyFamilyProvider(familyPrefix, [ + provider, + ]); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }, + ); + return { component: selector, focus: selector.getSettingsList() }; + }); + } + private setupEditorSubmitHandler(): void { setupEditorSubmitHandlerController(this as any); } @@ -3600,6 +3804,94 @@ export class InteractiveMode { } } + private async handleBashCommand( + command: string, + excludeFromContext = false, + displayCommand?: string, + loginShell?: boolean, + ): Promise { + const extensionRunner = this.session.extensionRunner; + const label = displayCommand || command; + const eventResult = extensionRunner + ? await extensionRunner.emitUserBash({ + type: "user_bash", + command, + excludeFromContext, + cwd: process.cwd(), + }) + : undefined; + + this.bashComponent = new BashExecutionComponent( + label, + this.ui, + excludeFromContext, + ); + if (this.session.isStreaming) { + this.pendingMessagesContainer.addChild(this.bashComponent); + this.pendingBashComponents.push(this.bashComponent); + } else { + this.chatContainer.addChild(this.bashComponent); + } + + const interceptedResult = eventResult?.result; + if (interceptedResult) { + if (interceptedResult.output) { + this.bashComponent.appendOutput(interceptedResult.output); + } + this.bashComponent.setComplete( + interceptedResult.exitCode, + interceptedResult.cancelled, + interceptedResult.truncated + ? ({ + truncated: true, + content: interceptedResult.output, + } as TruncationResult) + : undefined, + interceptedResult.fullOutputPath, + ); + this.session.recordBashResult(command, interceptedResult, { + excludeFromContext, + }); + this.bashComponent = undefined; + this.ui.requestRender(); + return; + } + + this.ui.requestRender(); + try { + const result = await this.session.executeBash( + command, + (chunk) => { + this.bashComponent?.appendOutput(chunk); + this.ui.requestRender(); + }, + { + excludeFromContext, + operations: eventResult?.operations, + loginShell, + }, + ); + this.bashComponent?.setComplete( + result.exitCode, + result.cancelled, + result.truncated + ? ({ truncated: true, content: result.output } as TruncationResult) + : undefined, + result.fullOutputPath, + ); + } catch (error) { + this.bashComponent?.setComplete(undefined, false); + this.showError( + `Bash command failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + + this.bashComponent = undefined; + this.ui.requestRender(); + } + private async executeCompaction( customInstructions?: string, isAuto = false, diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 29b9f2d2e..8347daa15 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -310,6 +310,10 @@ export function syncResourceDir(srcDir: string, destDir: string): void { return; } + if (existsSync(destDir) && lstatSync(destDir).isSymbolicLink()) { + rmSync(destDir, { force: true }); + } + makeTreeWritable(destDir); pruneStaleSiblingFiles(srcDir, destDir); for (const entry of readdirSync(srcDir, { withFileTypes: true })) { diff --git a/src/resources/extensions/sf/bootstrap/db-tools.js b/src/resources/extensions/sf/bootstrap/db-tools.js index cab515d23..61882ac1e 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.js +++ b/src/resources/extensions/sf/bootstrap/db-tools.js @@ -8,6 +8,7 @@ import { nextMilestoneId, } from "../guided-flow.js"; import { loadEffectiveSFPreferences } from "../preferences.js"; +import { ALL_SCHEDULE_KINDS } from "../schedule/schedule-types.js"; import { markResolved, recordSelfFeedback } from "../self-feedback.js"; import { executeCompleteMilestone, @@ -1013,10 +1014,12 @@ export function registerDbTools(pi) { }), }), ), - kind: Type.String({ - description: - "Entry kind (reminder, milestone_check, review_due, recurring)", - }), + kind: Type.Union( + ALL_SCHEDULE_KINDS.map((k) => Type.Literal(k)), + { + description: `Entry kind (${ALL_SCHEDULE_KINDS.join(", ")})`, + }, + ), title: Type.String({ description: "Entry title / reminder message", }), @@ -1921,10 +1924,12 @@ export function registerDbTools(pi) { }), }), ), - kind: Type.String({ - description: - "Entry kind (reminder, milestone_check, review_due, recurring)", - }), + kind: Type.Union( + ALL_SCHEDULE_KINDS.map((k) => Type.Literal(k)), + { + description: `Entry kind (${ALL_SCHEDULE_KINDS.join(", ")})`, + }, + ), title: Type.String({ description: "Entry title / reminder message", }), diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index ebcbc8c4f..86ebecb1f 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -91,6 +91,31 @@ import { // printed it before the TUI launched. Only re-print on /clear (subsequent sessions). let isFirstSession = true; let lastGeminiPreflightWarning; + +async function runSessionStartupDoctorFix(ctx) { + if (process.env.SF_DISABLE_STARTUP_DOCTOR === "1") return; + try { + const { runSFDoctor, summarizeDoctorIssues } = await import("../doctor.js"); + const report = await runSFDoctor(process.cwd(), { fix: true }); + if (report.fixesApplied.length > 0) { + ctx.ui?.notify?.( + `Startup doctor: applied ${report.fixesApplied.length} fix(es).`, + "info", + ); + } + const summary = summarizeDoctorIssues(report.issues); + if (summary.errors > 0) { + ctx.ui?.notify?.( + `Startup doctor found ${summary.errors} blocking issue(s). Run /sf doctor audit for details.`, + "warning", + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + safetyLogWarning("startup-doctor", msg); + } +} + async function syncServiceTierStatus(ctx) { const { getEffectiveServiceTier, @@ -129,6 +154,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { resetAskUserQuestionsCache(); await syncServiceTierStatus(ctx); await initializeLearningRuntime(); + await runSessionStartupDoctorFix(ctx); // Apply show_token_cost preference (#1515) try { const { loadEffectiveSFPreferences } = await import("../preferences.js"); diff --git a/src/resources/extensions/sf/doctor.js b/src/resources/extensions/sf/doctor.js index e657d6486..5e6da56e7 100644 --- a/src/resources/extensions/sf/doctor.js +++ b/src/resources/extensions/sf/doctor.js @@ -5,8 +5,10 @@ import { readdirSync, readFileSync, rmSync, + writeFileSync, } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, extname, join } from "node:path"; +import { parse as parseYaml } from "yaml"; import { invalidateAllCaches } from "./cache.js"; import { checkEngineHealth, @@ -24,6 +26,7 @@ import { parseTaskPlanMustHaves, saveFile, } from "./files.js"; +import { nativeScanSfTree } from "./native-parser-bridge.js"; import { parsePlan, parseRoadmap } from "./parsers.js"; import { milestonesDir, @@ -62,6 +65,13 @@ const LEGACY_ROOT_HARNESS_PATHS = [ "harness/evals/AGENTS.md", "harness/graders/AGENTS.md", ]; +const SF_FORM_LINT_SKIP_DIRS = new Set([ + "node_modules", + "worktrees", + "sift", + ".sift", +]); +const SF_LEGACY_BRANDING_RE = /\bGSD\b/g; function pruneEmptyDir(path) { try { @@ -128,6 +138,133 @@ function checkGeneratedArtifactResidue( } } +function walkSfTreeFallback(root, prefix = "") { + const entries = []; + if (!existsSync(root)) return entries; + for (const name of readdirSync(root)) { + const fullPath = join(root, name); + const relPath = prefix ? `${prefix}/${name}` : name; + let stat; + try { + stat = lstatSync(fullPath); + } catch { + continue; + } + const isDir = stat.isDirectory(); + entries.push({ path: relPath, name, isDir }); + if (isDir && !SF_FORM_LINT_SKIP_DIRS.has(name)) { + entries.push(...walkSfTreeFallback(fullPath, relPath)); + } + } + return entries; +} + +function collectSfFormFiles(basePath) { + const root = sfRoot(basePath); + if (!existsSync(root)) return []; + const scanned = nativeScanSfTree(root) ?? walkSfTreeFallback(root); + return scanned + .filter((entry) => !entry.isDir) + .map((entry) => entry.path) + .filter((relPath) => { + const parts = relPath.split("/"); + return !parts.some((part) => SF_FORM_LINT_SKIP_DIRS.has(part)); + }); +} + +function parseJsonl(content) { + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + try { + JSON.parse(line); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return `line ${i + 1}: ${msg}`; + } + } + return null; +} + +function parseMarkdownFrontmatter(content) { + if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) { + return null; + } + const normalized = content.replace(/\r\n/g, "\n"); + const end = normalized.indexOf("\n---\n", 4); + if (end === -1) return "frontmatter opening marker has no closing marker"; + const frontmatter = normalized.slice(4, end); + try { + parseYaml(frontmatter); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +function checkSfFormSyntax(basePath, issues, fixesApplied, shouldFix) { + const root = sfRoot(basePath); + for (const relPath of collectSfFormFiles(basePath)) { + const filePath = join(root, relPath); + let content; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + continue; + } + const ext = extname(relPath).toLowerCase(); + let parseError = null; + try { + if (ext === ".json") { + JSON.parse(content); + } else if (ext === ".jsonl") { + parseError = parseJsonl(content); + } else if (ext === ".yaml" || ext === ".yml") { + parseYaml(content); + } else if (ext === ".md") { + parseError = parseMarkdownFrontmatter(content); + } + } catch (error) { + parseError = error instanceof Error ? error.message : String(error); + } + if (parseError) { + issues.push({ + severity: "error", + code: "invalid_sf_form", + scope: "project", + unitId: "project", + message: `.sf/${relPath} has invalid ${ext.slice(1) || "form"} syntax: ${parseError}`, + file: `.sf/${relPath}`, + fixable: false, + }); + } + + if ( + (relPath.endsWith("AGENTS.md") || relPath.endsWith("CLAUDE.md")) && + SF_LEGACY_BRANDING_RE.test(content) + ) { + issues.push({ + severity: "warning", + code: "legacy_gsd_scaffold_text", + scope: "project", + unitId: "project", + message: `.sf/${relPath} contains legacy GSD naming; generated SF guidance should use SF naming.`, + file: `.sf/${relPath}`, + fixable: true, + }); + if (shouldFix("legacy_gsd_scaffold_text")) { + writeFileSync( + filePath, + content.replace(SF_LEGACY_BRANDING_RE, "SF"), + "utf-8", + ); + fixesApplied.push(`rewrote legacy GSD naming in .sf/${relPath}`); + } + } + } +} + function parseEpochMs(value, fallbackMs) { if (typeof value === "number" && Number.isFinite(value)) { return value < 10_000_000_000 ? value * 1000 : value; @@ -1100,6 +1237,7 @@ export async function runSFDoctor(basePath, options) { } } checkGeneratedArtifactResidue(basePath, issues, fixesApplied, shouldFix); + checkSfFormSyntax(basePath, issues, fixesApplied, shouldFix); // Git health checks — timed const t0git = Date.now(); const isolationMode = diff --git a/src/resources/extensions/sf/prompts/plan-milestone.md b/src/resources/extensions/sf/prompts/plan-milestone.md index 282e49fbb..f7819a0d4 100644 --- a/src/resources/extensions/sf/prompts/plan-milestone.md +++ b/src/resources/extensions/sf/prompts/plan-milestone.md @@ -135,7 +135,12 @@ schedule: - Top-level `in:` entries fire at milestone **creation** time (due_at = now + duration). - `on_complete.in:` entries fire when the milestone is **completed** (due_at = completion_time + duration). -- `kind` must be one of: `reminder`, `milestone_check`, `review_due`, `recurring`. +- `kind` must be one of: `review`, `audit`, `reminder`, `milestone_check`, `recurring`. + - `review` — surface title to next planning turn as context (preferred). + - `audit` — same as review but with stronger visual weight. + - `reminder` — general reminder. + - `milestone_check` — milestone health check. + - `recurring` — cron-based recurring entry. - `title` becomes the reminder message. - `payload` is optional kind-specific data. diff --git a/src/resources/extensions/sf/schedule/schedule-types.js b/src/resources/extensions/sf/schedule/schedule-types.js index 1221ee53b..1e595b390 100644 --- a/src/resources/extensions/sf/schedule/schedule-types.js +++ b/src/resources/extensions/sf/schedule/schedule-types.js @@ -20,7 +20,12 @@ */ /** - * @typedef {("reminder"|"milestone_check"|"review_due"|"recurring")} ScheduleKind + * @typedef {("reminder"|"milestone_check"|"review_due"|"recurring"|"review"|"audit")} ScheduleKind + * "review" / "audit" — surfaced to next planning turn (SF schedule system kinds). + * "review_due" — legacy internal name for review (backward compat). + * "reminder" — general reminder. + * "milestone_check" — milestone health check. + * "recurring" — cron-based recurring entry. */ /** @@ -87,13 +92,23 @@ // ─── Guards ───────────────────────────────────────────────────────────────── /** @type {Set} */ -const VALID_KINDS = new Set([ +export const VALID_KINDS = new Set([ "reminder", "milestone_check", "review_due", "recurring", + "review", + "audit", ]); +/** + * All valid schedule kinds — single source of truth for tooling and validation. + * Update VALID_KINDS and ScheduleKind typedef together. + * + * @type {string[]} + */ +export const ALL_SCHEDULE_KINDS = [...VALID_KINDS]; + /** * Validate that a string is a known schedule kind. * diff --git a/src/resources/extensions/sf/tests/doctor-sf-form-lint.test.mjs b/src/resources/extensions/sf/tests/doctor-sf-form-lint.test.mjs new file mode 100644 index 000000000..dcb067193 --- /dev/null +++ b/src/resources/extensions/sf/tests/doctor-sf-form-lint.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { runSFDoctor } from "../doctor.js"; + +const tmpDirs = []; + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-doctor-form-lint-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf"), { recursive: true }); + return dir; +} + +describe("doctor .sf form lint", () => { + test("runSFDoctor_reports_invalid_json_yaml_jsonl_and_markdown_frontmatter", async () => { + const project = makeProject(); + writeFileSync(join(project, ".sf", "bad.json"), "{", "utf-8"); + writeFileSync(join(project, ".sf", "bad.yaml"), "a: [", "utf-8"); + writeFileSync(join(project, ".sf", "bad.jsonl"), '{"ok":true}\n{', "utf-8"); + writeFileSync( + join(project, ".sf", "bad.md"), + "---\ntitle: [\n---\n# Bad\n", + "utf-8", + ); + + const report = await runSFDoctor(project, { scope: "project" }); + const invalidFiles = report.issues + .filter((issue) => issue.code === "invalid_sf_form") + .map((issue) => issue.file) + .sort(); + + assert.deepEqual(invalidFiles, [ + ".sf/bad.json", + ".sf/bad.jsonl", + ".sf/bad.md", + ".sf/bad.yaml", + ]); + }); + + test("runSFDoctor_fix_rewrites_legacy_gsd_scaffold_text_in_sf_guidance", async () => { + const project = makeProject(); + writeFileSync( + join(project, ".sf", "AGENTS.md"), + "# GSD guidance\n\nGSD owns this scaffold.\n", + "utf-8", + ); + + const report = await runSFDoctor(project, { + fix: true, + fixLevel: "all", + scope: "project", + }); + + assert.ok( + report.fixesApplied.includes( + "rewrote legacy GSD naming in .sf/AGENTS.md", + ), + ); + assert.equal(existsSync(join(project, ".sf", "AGENTS.md")), true); + assert.equal( + readFileSync(join(project, ".sf", "AGENTS.md"), "utf-8"), + "# SF guidance\n\nSF owns this scaffold.\n", + ); + }); +}); diff --git a/src/resources/extensions/sf/tests/schedule-kinds.test.mjs b/src/resources/extensions/sf/tests/schedule-kinds.test.mjs new file mode 100644 index 000000000..81649031e --- /dev/null +++ b/src/resources/extensions/sf/tests/schedule-kinds.test.mjs @@ -0,0 +1,109 @@ +/** + * Schedule Kind Synchronisation — tooling test. + * + * Purpose: verify that every schedule kind mentioned in prompts and documentation + * is present in VALID_KINDS (the single source of truth in schedule-types.js). + * Catches drift where a prompt documents a kind that is silently rejected by + * isValidKind(). + * + * Consumer: CI test runner (vitest). Run via `npm test`. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it } from "vitest"; +import { ALL_SCHEDULE_KINDS, VALID_KINDS } from "../schedule/schedule-types.js"; + +// Files that document or schema-validate ScheduleSpec.kind. +const TRACKED_FILES = [ + join(import.meta.dirname, "../prompts/plan-milestone.md"), + join(import.meta.dirname, "../prompts/guided-plan-milestone.md"), + join(import.meta.dirname, "../bootstrap/db-tools.js"), +]; + +/** + * Extract all double-quoted strings that look like schedule kinds from a file. + * Matches patterns like kind: "review" or 'review' in YAML/markdown context. + * + * @param {string} content + * @returns {string[]} + */ +function extractKindLiterals(content) { + const found = new Set(); + const re = /(?:kind\s*[:=]\s*["'])([a-z_]+)["']/gi; + let match; + while ((match = re.exec(content)) !== null) { + found.add(match[1].toLowerCase()); + } + + return [...found]; +} + +describe("schedule-kinds sync", () => { + describe("VALID_KINDS and ALL_SCHEDULE_KINDS", () => { + it("ALL_SCHEDULE_KINDS is a non-empty array", () => { + assert.ok(Array.isArray(ALL_SCHEDULE_KINDS)); + assert.ok(ALL_SCHEDULE_KINDS.length > 0); + }); + + it("ALL_SCHEDULE_KINDS matches VALID_KINDS contents", () => { + assert.deepEqual(ALL_SCHEDULE_KINDS.sort(), [...VALID_KINDS].sort()); + }); + + it("every ALL_SCHEDULE_KINDS entry is a non-empty string", () => { + for (const k of ALL_SCHEDULE_KINDS) { + assert.equal(typeof k, "string"); + assert.ok(k.length > 0, `empty kind in ALL_SCHEDULE_KINDS`); + } + }); + + it("VALID_KINDS has no duplicate entries", () => { + const seen = new Set(); + for (const k of VALID_KINDS) { + assert.ok(!seen.has(k), `duplicate kind: ${k}`); + seen.add(k); + } + }); + }); + + describe("prompt kind references are in VALID_KINDS", () => { + for (const filePath of TRACKED_FILES) { + const content = readFileSync(filePath, "utf-8"); + const mentioned = extractKindLiterals(content); + + if (mentioned.length === 0) { + // No kind references found — skip but report + it(`${filePath.split("/").pop()}: finds no kind literals (add to TRACKED_FILES or remove from list)`, () => { + // pass — file may genuinely have no kind references + }); + continue; + } + + for (const kind of mentioned) { + it(`${filePath.split("/").pop()}: kind "${kind}" is in VALID_KINDS`, () => { + assert.ok( + VALID_KINDS.has(kind), + `"${kind}" is mentioned in ${filePath.split("/").pop()} ` + + `but is not in VALID_KINDS (${[...VALID_KINDS].join(", ")})`, + ); + }); + } + } + }); + + describe("VALID_KINDS has review and audit (SF schedule system kinds)", () => { + it('has "review" kind', () => { + assert.ok( + VALID_KINDS.has("review"), + "review kind missing from VALID_KINDS", + ); + }); + + it('has "audit" kind', () => { + assert.ok( + VALID_KINDS.has("audit"), + "audit kind missing from VALID_KINDS", + ); + }); + }); +}); diff --git a/src/resources/extensions/sf/tests/schedule-milestone.test.mjs b/src/resources/extensions/sf/tests/schedule-milestone.test.mjs new file mode 100644 index 000000000..bc7d11a91 --- /dev/null +++ b/src/resources/extensions/sf/tests/schedule-milestone.test.mjs @@ -0,0 +1,232 @@ +/** + * Schedule Milestone Integration — unit tests. + * + * Purpose: verify that appendScheduleSpec / appendScheduleSpecs correctly + * converts ScheduleSpec objects into ScheduleEntry records and appends them + * to the store. Covers all valid kinds, invalid-kind rejection, duration + * parsing, entry shape, and non-fatal error handling. + * + * Consumer: CI test runner (vitest). Run via `npm test`. + */ +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "vitest"; +import { + appendScheduleSpec, + appendScheduleSpecs, +} from "../schedule/schedule-milestone.js"; +import { createScheduleStore } from "../schedule/schedule-store.js"; + +/** @type {string} */ +let testDir; + +beforeEach(() => { + testDir = join( + tmpdir(), + `sf-schedule-milestone-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(join(testDir, ".sf"), { recursive: true }); +}); + +afterEach(() => { + try { + rmSync(testDir, { recursive: true }); + } catch { + // ignore + } +}); + +describe("appendScheduleSpec", () => { + describe("kind validation", () => { + for (const kind of [ + "review", + "audit", + "reminder", + "milestone_check", + "review_due", + "recurring", + ]) { + it(`kind "${kind}" creates an entry (not silently dropped)`, () => { + const spec = { + in: "1d", + kind, + title: `Test ${kind} entry`, + }; + appendScheduleSpec(testDir, spec, "M001"); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + + assert.ok( + entries.some((e) => e.kind === kind && e.status === "pending"), + `no pending entry with kind="${kind}" found. Entries: ${JSON.stringify(entries)}`, + ); + }); + } + + it("invalid kind is skipped (no throw, no entry created)", () => { + const spec = { + in: "1d", + kind: "not_a_real_kind", + title: "Should be silently dropped", + }; + // Should not throw + appendScheduleSpec(testDir, spec, "M001"); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + assert.equal(entries.length, 0, "invalid kind should create no entry"); + }); + }); + + describe("entry shape", () => { + it("creates entry with required fields", () => { + const spec = { + in: "2w", + kind: "review", + title: "Check adoption", + payload: { extra: "data" }, + }; + appendScheduleSpec(testDir, spec, "M042"); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + + assert.equal(entries.length, 1); + const entry = entries[0]; + assert.equal(entry.kind, "review"); + assert.equal(entry.status, "pending"); + assert.equal(entry.created_by, "milestone"); + assert.equal(entry.payload.milestoneId, "M042"); + assert.equal(entry.payload.message, "Check adoption"); + assert.equal(entry.payload.extra, "data"); + assert.ok(typeof entry.id === "string" && entry.id.length === 28); + assert.ok(typeof entry.due_at === "string"); + assert.ok(typeof entry.created_at === "string"); + }); + + it("sets due_at correctly for in: duration", () => { + const before = Date.now(); + const spec = { in: "1d", kind: "reminder", title: "Daily check" }; + appendScheduleSpec(testDir, spec); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + const dueMs = new Date(entries[0].due_at).getTime(); + const expectedMs = before + 24 * 60 * 60 * 1000; + + assert.ok( + Math.abs(dueMs - expectedMs) < 5000, + `due_at ${dueMs} is more than 5s from expected ${expectedMs}`, + ); + }); + + it("sets due_at correctly for on_complete.in duration", () => { + const before = Date.now(); + const spec = { + on_complete: { in: "3d" }, + kind: "audit", + title: "Post-completion audit", + }; + appendScheduleSpec(testDir, spec, "M001"); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + const dueMs = new Date(entries[0].due_at).getTime(); + const expectedMs = before + 3 * 24 * 60 * 60 * 1000; + + assert.ok( + Math.abs(dueMs - expectedMs) < 5000, + `due_at ${dueMs} is more than 5s from expected ${expectedMs}`, + ); + }); + + it("skips spec with no in or on_complete.in (no entry, no throw)", () => { + const spec = { kind: "review", title: "No duration" }; + appendScheduleSpec(testDir, spec); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + assert.equal(entries.length, 0); + }); + }); + + describe("non-fatal error handling", () => { + it("throws nothing for null spec", () => { + assert.doesNotThrow(() => { + // @ts-expect-error — intentionally passing bad input + appendScheduleSpec(testDir, null); + }); + }); + + it("throws nothing for non-object spec", () => { + assert.doesNotThrow(() => { + // @ts-expect-error + appendScheduleSpec(testDir, "not an object"); + }); + }); + + it("skips spec without milestoneId when milestoneId is undefined", () => { + const spec = { in: "1h", kind: "audit", title: "Ad-hoc audit" }; + appendScheduleSpec(testDir, spec); // no milestoneId arg + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + assert.equal(entries.length, 1); + assert.equal(entries[0].payload.milestoneId, undefined); + }); + }); +}); + +describe("appendScheduleSpecs", () => { + it("processes an array of specs", () => { + const specs = [ + { in: "1d", kind: "review", title: "First review" }, + { in: "2d", kind: "audit", title: "Second audit" }, + { in: "3d", kind: "reminder", title: "Third reminder" }, + ]; + appendScheduleSpecs(testDir, specs, "M001"); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + + assert.equal(entries.length, 3); + assert.ok(entries.some((e) => e.kind === "review")); + assert.ok(entries.some((e) => e.kind === "audit")); + assert.ok(entries.some((e) => e.kind === "reminder")); + }); + + it("one bad spec does not block others", () => { + const specs = [ + { in: "1d", kind: "review", title: "Good one" }, + { in: "2d", kind: "bad_kind", title: "Bad kind" }, + { in: "3d", kind: "audit", title: "Good two" }, + ]; + appendScheduleSpecs(testDir, specs, "M001"); + + const store = createScheduleStore(testDir); + const entries = store.readEntries("project"); + + assert.equal( + entries.length, + 2, + "bad kind should be skipped, others preserved", + ); + assert.ok(entries.every((e) => e.kind !== "bad_kind")); + }); + + it("handles empty array without throw", () => { + assert.doesNotThrow(() => { + appendScheduleSpecs(testDir, [], "M001"); + }); + }); + + it("handles non-array input without throw", () => { + assert.doesNotThrow(() => { + // @ts-expect-error + appendScheduleSpecs(testDir, "not an array", "M001"); + }); + }); +}); diff --git a/src/resources/extensions/sf/tests/schedule-store.test.mjs b/src/resources/extensions/sf/tests/schedule-store.test.mjs index 5a8acbaa3..c28d1e24e 100644 --- a/src/resources/extensions/sf/tests/schedule-store.test.mjs +++ b/src/resources/extensions/sf/tests/schedule-store.test.mjs @@ -26,6 +26,8 @@ describe("schedule-types", () => { assert.equal(isValidKind("milestone_check"), true); assert.equal(isValidKind("review_due"), true); assert.equal(isValidKind("recurring"), true); + assert.equal(isValidKind("review"), true); + assert.equal(isValidKind("audit"), true); }); it("rejects unknown kinds", () => { diff --git a/src/tests/resource-loader.test.ts b/src/tests/resource-loader.test.ts index c46a9edb7..5a39345c8 100644 --- a/src/tests/resource-loader.test.ts +++ b/src/tests/resource-loader.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -139,6 +141,44 @@ test("buildResourceLoader excludes duplicate top-level pi extensions when bundle ); }); +test("syncResourceDir replaces stale dev symlink before built resource sync", async () => { + const { syncResourceDir } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "sf-resource-loader-symlink-")); + const bundledDir = join(tmp, "dist-resources", "extensions"); + const sourceDir = join(tmp, "src-resources", "extensions"); + const agentExtensionsDir = join(tmp, "agent", "extensions"); + const sourceDeclaration = join(sourceDir, "sf", "types.d.ts"); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + mkdirSync(join(bundledDir, "sf"), { recursive: true }); + mkdirSync(join(sourceDir, "sf"), { recursive: true }); + mkdirSync(join(tmp, "agent"), { recursive: true }); + writeFileSync(join(bundledDir, "sf", "index.js"), "export {};\n"); + writeFileSync(sourceDeclaration, "export type Preserved = true;\n"); + symlinkSync(sourceDir, agentExtensionsDir, "dir"); + + syncResourceDir(bundledDir, agentExtensionsDir); + + assert.equal( + lstatSync(agentExtensionsDir).isSymbolicLink(), + false, + "built resource sync must replace stale dev symlink instead of following it", + ); + assert.equal( + existsSync(sourceDeclaration), + true, + "built resource sync must not prune declaration files from src/resources", + ); + assert.equal( + existsSync(join(agentExtensionsDir, "sf", "index.js")), + true, + "built resources should be copied into the agent extensions directory", + ); +}); + test("initResources manifest tracks all bundled extension subdirectories including remote-questions (#2367)", async () => { const { initResources } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "sf-resource-loader-manifest-"));