fix: stabilize sf startup and state linting
This commit is contained in:
parent
46db1e95ef
commit
87d49abd87
16 changed files with 1111 additions and 34 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 })) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>} */
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
109
src/resources/extensions/sf/tests/schedule-kinds.test.mjs
Normal file
109
src/resources/extensions/sf/tests/schedule-kinds.test.mjs
Normal file
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
232
src/resources/extensions/sf/tests/schedule-milestone.test.mjs
Normal file
232
src/resources/extensions/sf/tests/schedule-milestone.test.mjs
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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-"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue