diff --git a/src/agent/clientSkills.ts b/src/agent/clientSkills.ts new file mode 100644 index 0000000..c9029c5 --- /dev/null +++ b/src/agent/clientSkills.ts @@ -0,0 +1,101 @@ +import { join } from "node:path"; +import type { MessageCreateParams as ConversationMessageCreateParams } from "@letta-ai/letta-client/resources/conversations/messages"; +import { getSkillSources, getSkillsDirectory } from "./context"; +import { + compareSkills, + discoverSkills, + SKILLS_DIR, + type Skill, + type SkillDiscoveryError, + type SkillSource, +} from "./skills"; + +export type ClientSkill = NonNullable< + ConversationMessageCreateParams["client_skills"] +>[number]; + +export interface BuildClientSkillsPayloadOptions { + agentId?: string; + skillsDirectory?: string | null; + skillSources?: SkillSource[]; + discoverSkillsFn?: typeof discoverSkills; + logger?: (message: string) => void; +} + +export interface BuildClientSkillsPayloadResult { + clientSkills: NonNullable; + skillPathById: Record; + errors: SkillDiscoveryError[]; +} + +function toClientSkill(skill: Skill): ClientSkill { + return { + name: skill.id, + description: skill.description, + location: skill.path, + }; +} + +function resolveSkillDiscoveryContext( + options: BuildClientSkillsPayloadOptions, +): { + skillsDirectory: string; + skillSources: SkillSource[]; +} { + const skillsDirectory = + options.skillsDirectory ?? + getSkillsDirectory() ?? + join(process.cwd(), SKILLS_DIR); + const skillSources = options.skillSources ?? getSkillSources(); + return { skillsDirectory, skillSources }; +} + +/** + * Build `client_skills` payload for conversations.messages.create. + * + * This discovers client-side skills using the same source selection rules as the + * Skill tool and headless startup flow, then converts them into the server-facing + * schema expected by the API. Ordering is deterministic by skill id. + */ +export async function buildClientSkillsPayload( + options: BuildClientSkillsPayloadOptions = {}, +): Promise { + const { skillsDirectory, skillSources } = + resolveSkillDiscoveryContext(options); + const discoverSkillsFn = options.discoverSkillsFn ?? discoverSkills; + + try { + const discovery = await discoverSkillsFn(skillsDirectory, options.agentId, { + sources: skillSources, + }); + const sortedSkills = [...discovery.skills].sort(compareSkills); + + return { + clientSkills: sortedSkills.map(toClientSkill), + skillPathById: Object.fromEntries( + sortedSkills + .filter( + (skill) => typeof skill.path === "string" && skill.path.length > 0, + ) + .map((skill) => [skill.id, skill.path]), + ), + errors: discovery.errors, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : `Unknown error: ${String(error)}`; + options.logger?.(`Failed to build client_skills payload: ${message}`); + return { + clientSkills: [], + skillPathById: {}, + errors: [ + { + path: skillsDirectory, + message, + }, + ], + }; + } +} diff --git a/src/agent/message.ts b/src/agent/message.ts index 0f0ed01..1691714 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -8,6 +8,7 @@ import type { ApprovalCreate, LettaStreamingResponse, } from "@letta-ai/letta-client/resources/agents/messages"; +import type { MessageCreateParams as ConversationMessageCreateParams } from "@letta-ai/letta-client/resources/conversations/messages"; import { type ClientTool, captureToolExecutionContext, @@ -19,6 +20,7 @@ import { normalizeOutgoingApprovalMessages, } from "./approval-result-normalization"; import { getClient } from "./client"; +import { buildClientSkillsPayload } from "./clientSkills"; const streamRequestStartTimes = new WeakMap(); const streamToolContextIds = new WeakMap(); @@ -60,6 +62,9 @@ export function buildConversationMessagesCreateRequestBody( messages: Array, opts: SendMessageStreamOptions = { streamTokens: true, background: true }, clientTools: ClientTool[], + clientSkills: NonNullable< + ConversationMessageCreateParams["client_skills"] + > = [], ) { const isDefaultConversation = conversationId === "default"; if (isDefaultConversation && !opts.agentId) { @@ -77,6 +82,7 @@ export function buildConversationMessagesCreateRequestBody( stream_tokens: opts.streamTokens ?? true, include_pings: true, background: opts.background ?? true, + client_skills: clientSkills, client_tools: clientTools, include_compaction_messages: true, ...(isDefaultConversation ? { agent_id: opts.agentId } : {}), @@ -113,6 +119,10 @@ export async function sendMessageStream( // This prevents sending messages with stale tools during a switch await waitForToolsetReady(); const { clientTools, contextId } = captureToolExecutionContext(); + const { clientSkills, errors: clientSkillDiscoveryErrors } = + await buildClientSkillsPayload({ + agentId: opts.agentId, + }); const resolvedConversationId = conversationId; const requestBody = buildConversationMessagesCreateRequestBody( @@ -120,12 +130,30 @@ export async function sendMessageStream( messages, opts, clientTools, + clientSkills, ); if (process.env.DEBUG) { console.log( `[DEBUG] sendMessageStream: conversationId=${conversationId}, agentId=${opts.agentId ?? "(none)"}`, ); + + const formattedSkills = clientSkills.map( + (skill) => `${skill.name} (${skill.location})`, + ); + console.log( + `[DEBUG] sendMessageStream: client_skills (${clientSkills.length}) ${ + formattedSkills.length > 0 ? formattedSkills.join(", ") : "(none)" + }`, + ); + + if (clientSkillDiscoveryErrors.length > 0) { + for (const error of clientSkillDiscoveryErrors) { + console.warn( + `[DEBUG] sendMessageStream: client_skills discovery error at ${error.path}: ${error.message}`, + ); + } + } } const extraHeaders: Record = {}; diff --git a/src/agent/prompts/letta_claude.md b/src/agent/prompts/letta_claude.md index 52b282a..412e45d 100644 --- a/src/agent/prompts/letta_claude.md +++ b/src/agent/prompts/letta_claude.md @@ -132,4 +132,4 @@ assistant: Clients are marked as failed in the `connectToServer` function in src # Skills - - / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands. \ No newline at end of file + - / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands. \ No newline at end of file diff --git a/src/agent/prompts/letta_codex.md b/src/agent/prompts/letta_codex.md index 9877e62..ca8b9b3 100644 --- a/src/agent/prompts/letta_codex.md +++ b/src/agent/prompts/letta_codex.md @@ -114,4 +114,4 @@ Unless the user explicitly asks for a plan, asks a question about the code, is b ## Skills -- / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands. +- / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands. diff --git a/src/agent/prompts/letta_gemini.md b/src/agent/prompts/letta_gemini.md index d71c67c..78bed95 100644 --- a/src/agent/prompts/letta_gemini.md +++ b/src/agent/prompts/letta_gemini.md @@ -79,4 +79,4 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Remembering Facts:** Use the memory tools available to you to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you? # Skills - - / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands. \ No newline at end of file + - / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands. \ No newline at end of file diff --git a/src/agent/prompts/skill_creator_mode.md b/src/agent/prompts/skill_creator_mode.md index 16458ae..638fb64 100644 --- a/src/agent/prompts/skill_creator_mode.md +++ b/src/agent/prompts/skill_creator_mode.md @@ -11,7 +11,7 @@ Your goal is to guide the user through a **focused, collaborative workflow** to ## 1. Invoke the creating-skills Skill (if available) -1. Check the available skills listed in system-reminder messages in the conversation. +1. Check the available skills listed in the current prompt context in the conversation. 2. If a `creating-skills` skill is available, invoke it using the `Skill` tool: - Call the `Skill` tool with: `skill: "creating-skills"` 3. If invocation fails or the skill is not available, continue using your own judgment based on these instructions. diff --git a/src/agent/prompts/system_prompt.txt b/src/agent/prompts/system_prompt.txt index 0c656ac..270f14d 100644 --- a/src/agent/prompts/system_prompt.txt +++ b/src/agent/prompts/system_prompt.txt @@ -17,4 +17,4 @@ When the user directly asks about Letta Code (eg 'can Letta Code do...', 'does L When running in Letta Code, shell tools provide `AGENT_ID`: your current agent ID. # Skills - - / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in system-reminder messages in the conversation - do not guess or use built-in CLI commands. + - / (e.g., /commit) is shorthand for users to invoke a skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in the available skills context in the conversation - do not guess or use built-in CLI commands. diff --git a/src/agent/skills.ts b/src/agent/skills.ts index 753d410..161e29b 100644 --- a/src/agent/skills.ts +++ b/src/agent/skills.ts @@ -85,6 +85,14 @@ export interface SkillDiscoveryError { message: string; } +export function compareSkills(a: Skill, b: Skill): number { + return ( + a.id.localeCompare(b.id) || + a.source.localeCompare(b.source) || + a.path.localeCompare(b.path) + ); +} + /** * Default directory name where project skills are stored */ @@ -223,7 +231,7 @@ export async function discoverSkills( } return { - skills: Array.from(skillsById.values()), + skills: Array.from(skillsById.values()).sort(compareSkills), errors: allErrors, }; } @@ -406,7 +414,9 @@ export function formatSkillsAsSystemReminder(skills: Skill[]): string { return ""; } - const lines = skills.map((s) => `- ${s.id} (${s.source}): ${s.description}`); + const lines = [...skills] + .sort(compareSkills) + .map((s) => `- ${s.id} (${s.source}): ${s.description}`); return ` The following skills are available for use with the Skill tool: diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 6d89adc..60b48df 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -8182,9 +8182,8 @@ export default function App({ cmd.finish(outputLines.join("\n"), true); // Manual /compact bypasses stream compaction events, so trigger - // post-compaction reminder/skills reinjection on the next user turn. + // post-compaction reflection reminder/auto-launch on the next user turn. contextTrackerRef.current.pendingReflectionTrigger = true; - contextTrackerRef.current.pendingSkillsReinject = true; } catch (error) { let errorOutput: string; diff --git a/src/cli/commands/runner.ts b/src/cli/commands/runner.ts index 7c13512..b7c31e7 100644 --- a/src/cli/commands/runner.ts +++ b/src/cli/commands/runner.ts @@ -73,13 +73,16 @@ export function createCommandRunner({ onCommandFinished, }: RunnerDeps) { function getHandle(id: string, input: string): CommandHandle { - // biome-ignore lint/style/noNonNullAssertion: forward-reference pattern — overwritten synchronously below. null! preferred over no-ops to crash loudly if invariant breaks. + const uninitialized = (): never => { + throw new Error("CommandHandle callback used before initialization"); + }; + const handle: CommandHandle = { id, input, - update: null!, - finish: null!, - fail: null!, + update: uninitialized, + finish: uninitialized, + fail: uninitialized, }; const update = (updateData: CommandUpdate) => { diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index a5ae960..a289df3 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -454,7 +454,6 @@ function extractTextPart(v: unknown): string { function markCompactionCompleted(ctx?: ContextTracker): void { if (!ctx) return; ctx.pendingCompaction = true; - ctx.pendingSkillsReinject = true; ctx.pendingReflectionTrigger = true; } diff --git a/src/cli/helpers/contextTracker.ts b/src/cli/helpers/contextTracker.ts index f7bcf6d..983a04e 100644 --- a/src/cli/helpers/contextTracker.ts +++ b/src/cli/helpers/contextTracker.ts @@ -16,8 +16,6 @@ export type ContextTracker = { currentTurnId: number; /** Set when a compaction event is seen; consumed by the next usage_statistics push */ pendingCompaction: boolean; - /** Set when compaction happens; consumed by the next user message to reinject skills reminder */ - pendingSkillsReinject: boolean; /** Set when compaction happens; consumed by the next user message to trigger memory reminder/spawn */ pendingReflectionTrigger: boolean; }; @@ -28,7 +26,6 @@ export function createContextTracker(): ContextTracker { contextTokensHistory: [], currentTurnId: 0, // simple in-memory counter for now pendingCompaction: false, - pendingSkillsReinject: false, pendingReflectionTrigger: false, }; } @@ -38,6 +35,5 @@ export function resetContextHistory(ct: ContextTracker): void { ct.lastContextTokens = 0; ct.contextTokensHistory = []; ct.pendingCompaction = false; - ct.pendingSkillsReinject = false; ct.pendingReflectionTrigger = false; } diff --git a/src/headless.ts b/src/headless.ts index dd6b6eb..b31804e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -21,6 +21,7 @@ import { } from "./agent/approval-recovery"; import { handleBootstrapSessionState } from "./agent/bootstrapHandler"; import { getClient } from "./agent/client"; +import { buildClientSkillsPayload } from "./agent/clientSkills"; import { setAgentContext, setConversationId } from "./agent/context"; import { createAgent } from "./agent/create"; import { handleListMessages } from "./agent/listMessagesHandler"; @@ -1459,13 +1460,22 @@ ${SYSTEM_REMINDER_CLOSE} // Pre-load specific skills' full content (used by subagents with skills: field) if (preLoadSkillsRaw) { const { readFile: readFileAsync } = await import("node:fs/promises"); + const { skillPathById } = await buildClientSkillsPayload({ + agentId: agent.id, + skillSources: resolvedSkillSources, + logger: (message) => { + if (process.env.DEBUG) { + console.warn(`[DEBUG] ${message}`); + } + }, + }); const skillIds = preLoadSkillsRaw .split(",") .map((s) => s.trim()) .filter(Boolean); const loadedContents: string[] = []; for (const skillId of skillIds) { - const skillPath = sharedReminderState.skillPathById[skillId]; + const skillPath = skillPathById[skillId]; if (!skillPath) continue; try { const content = await readFileAsync(skillPath, "utf-8"); diff --git a/src/reminders/catalog.ts b/src/reminders/catalog.ts index cba51f3..46f5675 100644 --- a/src/reminders/catalog.ts +++ b/src/reminders/catalog.ts @@ -7,7 +7,6 @@ export type SharedReminderMode = export type SharedReminderId = | "session-context" | "agent-info" - | "skills" | "permission-mode" | "plan-mode" | "reflection-step-count" @@ -40,11 +39,6 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray = "subagent", ], }, - { - id: "skills", - description: "Available skills system reminder (with reinjection)", - modes: ["interactive", "headless-one-shot", "headless-bidirectional"], - }, { id: "permission-mode", description: "Permission mode reminder", diff --git a/src/reminders/engine.ts b/src/reminders/engine.ts index bd83589..b4d82c3 100644 --- a/src/reminders/engine.ts +++ b/src/reminders/engine.ts @@ -1,12 +1,5 @@ -import { join } from "node:path"; import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; -import { getSkillsDirectory } from "../agent/context"; -import { - discoverSkills, - formatSkillsAsSystemReminder, - SKILLS_DIR, - type SkillSource, -} from "../agent/skills"; +import type { SkillSource } from "../agent/skills"; import { buildAgentInfo } from "../cli/helpers/agentInfo"; import { buildCompactionMemoryReminder, @@ -101,52 +94,6 @@ async function buildSessionContextReminder( return reminder || null; } -async function buildSkillsReminder( - context: SharedReminderContext, -): Promise { - const previousSkillsReminder = context.state.cachedSkillsReminder; - // Keep a stable empty baseline so a later successful discovery can diff - // against "" and trigger reinjection, even after an earlier discovery failure. - let latestSkillsReminder = previousSkillsReminder ?? ""; - - try { - const skillsDir = getSkillsDirectory() || join(process.cwd(), SKILLS_DIR); - const { skills } = await discoverSkills(skillsDir, context.agent.id, { - sources: context.skillSources, - }); - latestSkillsReminder = formatSkillsAsSystemReminder(skills); - context.state.skillPathById = Object.fromEntries( - skills - .filter( - (skill) => typeof skill.path === "string" && skill.path.length > 0, - ) - .map((skill) => [skill.id, skill.path as string]), - ); - } catch { - // Keep previous snapshot when discovery fails. - } - - if ( - previousSkillsReminder !== null && - previousSkillsReminder !== latestSkillsReminder - ) { - context.state.pendingSkillsReinject = true; - } - - context.state.cachedSkillsReminder = latestSkillsReminder; - - const shouldInject = - !context.state.hasInjectedSkillsReminder || - context.state.pendingSkillsReinject; - if (!shouldInject) { - return null; - } - - context.state.hasInjectedSkillsReminder = true; - context.state.pendingSkillsReinject = false; - return latestSkillsReminder || null; -} - async function buildPlanModeReminder( context: SharedReminderContext, ): Promise { @@ -398,7 +345,6 @@ export const sharedReminderProviders: Record< > = { "agent-info": buildAgentInfoReminder, "session-context": buildSessionContextReminder, - skills: buildSkillsReminder, "permission-mode": buildPermissionModeReminder, "plan-mode": buildPlanModeReminder, "reflection-step-count": buildReflectionStepReminder, diff --git a/src/reminders/state.ts b/src/reminders/state.ts index 4f5dd4c..42792f4 100644 --- a/src/reminders/state.ts +++ b/src/reminders/state.ts @@ -22,12 +22,8 @@ export interface ToolsetChangeReminder { export interface SharedReminderState { hasSentAgentInfo: boolean; hasSentSessionContext: boolean; - hasInjectedSkillsReminder: boolean; - cachedSkillsReminder: string | null; - skillPathById: Record; lastNotifiedPermissionMode: PermissionMode | null; turnCount: number; - pendingSkillsReinject: boolean; pendingReflectionTrigger: boolean; pendingAutoInitReminder: boolean; pendingCommandIoReminders: CommandIoReminder[]; @@ -40,12 +36,8 @@ export function createSharedReminderState(): SharedReminderState { return { hasSentAgentInfo: false, hasSentSessionContext: false, - hasInjectedSkillsReminder: false, - cachedSkillsReminder: null, - skillPathById: {}, lastNotifiedPermissionMode: null, turnCount: 0, - pendingSkillsReinject: false, pendingReflectionTrigger: false, pendingAutoInitReminder: false, pendingCommandIoReminders: [], @@ -63,10 +55,6 @@ export function syncReminderStateFromContextTracker( state: SharedReminderState, contextTracker: ContextTracker, ): void { - if (contextTracker.pendingSkillsReinject) { - state.pendingSkillsReinject = true; - contextTracker.pendingSkillsReinject = false; - } if (contextTracker.pendingReflectionTrigger) { state.pendingReflectionTrigger = true; contextTracker.pendingReflectionTrigger = false; diff --git a/src/tests/agent/clientSkills.test.ts b/src/tests/agent/clientSkills.test.ts new file mode 100644 index 0000000..3c615a4 --- /dev/null +++ b/src/tests/agent/clientSkills.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import type { Skill, SkillDiscoveryResult } from "../../agent/skills"; + +const baseSkill: Skill = { + id: "base", + name: "Base", + description: "Base skill", + path: "/tmp/base/SKILL.md", + source: "project", +}; + +describe("buildClientSkillsPayload", () => { + test("returns deterministically sorted client skills and path map", async () => { + const { buildClientSkillsPayload } = await import( + "../../agent/clientSkills" + ); + + const discoverSkillsFn = async (): Promise => ({ + skills: [ + { + ...baseSkill, + id: "z-skill", + description: "z", + path: "/tmp/z/SKILL.md", + source: "project", + }, + { + ...baseSkill, + id: "a-skill", + description: "a", + path: "/tmp/a/SKILL.md", + source: "bundled", + }, + ], + errors: [], + }); + + const result = await buildClientSkillsPayload({ + agentId: "agent-1", + skillsDirectory: "/tmp/.skills", + skillSources: ["project", "bundled"], + discoverSkillsFn, + }); + + expect(result.clientSkills).toEqual([ + { + name: "a-skill", + description: "a", + location: "/tmp/a/SKILL.md", + }, + { + name: "z-skill", + description: "z", + location: "/tmp/z/SKILL.md", + }, + ]); + expect(result.skillPathById).toEqual({ + "a-skill": "/tmp/a/SKILL.md", + "z-skill": "/tmp/z/SKILL.md", + }); + expect(result.errors).toEqual([]); + }); + + test("fails open with empty client_skills when discovery throws", async () => { + const { buildClientSkillsPayload } = await import( + "../../agent/clientSkills" + ); + + const discoverSkillsFn = async (): Promise => { + throw new Error("boom"); + }; + + const logs: string[] = []; + const result = await buildClientSkillsPayload({ + skillsDirectory: "/tmp/.skills", + discoverSkillsFn, + logger: (m) => logs.push(m), + }); + + expect(result.clientSkills).toEqual([]); + expect(result.skillPathById).toEqual({}); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.path).toBe("/tmp/.skills"); + expect( + logs.some((m) => m.includes("Failed to build client_skills payload")), + ).toBe(true); + }); +}); diff --git a/src/tests/agent/message-client-skills.test.ts b/src/tests/agent/message-client-skills.test.ts new file mode 100644 index 0000000..2743dbf --- /dev/null +++ b/src/tests/agent/message-client-skills.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { buildConversationMessagesCreateRequestBody } from "../../agent/message"; + +describe("buildConversationMessagesCreateRequestBody client_skills", () => { + test("includes client_skills alongside client_tools", () => { + const body = buildConversationMessagesCreateRequestBody( + "default", + [{ type: "message", role: "user", content: "hello" }], + { agentId: "agent-1", streamTokens: true, background: true }, + [ + { + name: "ShellCommand", + description: "Run shell command", + parameters: { type: "object", properties: {} }, + }, + ], + [ + { + name: "debugging", + description: "Debugging checklist", + location: "/tmp/.skills/debugging/SKILL.md", + }, + ], + ); + + expect(body.client_tools).toHaveLength(1); + expect(body.client_skills).toEqual([ + { + name: "debugging", + description: "Debugging checklist", + location: "/tmp/.skills/debugging/SKILL.md", + }, + ]); + }); +}); diff --git a/src/tests/agent/skills-discovery.test.ts b/src/tests/agent/skills-discovery.test.ts index f797ba2..9255272 100644 --- a/src/tests/agent/skills-discovery.test.ts +++ b/src/tests/agent/skills-discovery.test.ts @@ -107,5 +107,24 @@ describe.skipIf(process.platform === "win32")( result.errors.some((error) => error.path.includes("broken-link")), ).toBe(true); }); + + test("returns discovered skills in deterministic sorted order", async () => { + mkdirSync(projectSkillsDir, { recursive: true }); + writeSkill(join(projectSkillsDir, "z-skill"), "Z Skill"); + writeSkill(join(projectSkillsDir, "a-skill"), "A Skill"); + writeSkill(join(projectSkillsDir, "m-skill"), "M Skill"); + + const result = await discoverSkills(projectSkillsDir, undefined, { + skipBundled: true, + sources: ["project"], + }); + + expect(result.errors).toHaveLength(0); + expect(result.skills.map((skill) => skill.id)).toEqual([ + "a-skill", + "m-skill", + "z-skill", + ]); + }); }, ); diff --git a/src/tests/agent/skills-format.test.ts b/src/tests/agent/skills-format.test.ts index 2ab1e55..8004d9b 100644 --- a/src/tests/agent/skills-format.test.ts +++ b/src/tests/agent/skills-format.test.ts @@ -66,4 +66,53 @@ describe("Skills formatting (system reminder)", () => { expect(result).toContain("project-skill (project)"); expect(result).toContain("global-skill (global)"); }); + + test("sorts skills deterministically before formatting", () => { + const skills: Skill[] = [ + { + id: "z-skill", + name: "Z Skill", + description: "Last by id", + path: "/test/.skills/z-skill/SKILL.md", + source: "project", + }, + { + id: "a-skill", + name: "A Skill", + description: "First by id", + path: "/test/.skills/a-skill/SKILL.md", + source: "project", + }, + { + id: "same-id", + name: "Same Id Global", + description: "Global variant", + path: "/global/.skills/same-id/SKILL.md", + source: "global", + }, + { + id: "same-id", + name: "Same Id Project", + description: "Project variant", + path: "/project/.skills/same-id/SKILL.md", + source: "project", + }, + ]; + + const result = formatSkillsAsSystemReminder(skills); + + const aSkillIndex = result.indexOf("- a-skill (project): First by id"); + const sameIdGlobalIndex = result.indexOf( + "- same-id (global): Global variant", + ); + const sameIdProjectIndex = result.indexOf( + "- same-id (project): Project variant", + ); + const zSkillIndex = result.indexOf("- z-skill (project): Last by id"); + + expect(aSkillIndex).toBeGreaterThan(-1); + expect(sameIdGlobalIndex).toBeGreaterThan(aSkillIndex); + expect(sameIdProjectIndex).toBeGreaterThan(sameIdGlobalIndex); + expect(zSkillIndex).toBeGreaterThan(sameIdProjectIndex); + }); }); diff --git a/src/tests/cli/accumulator-usage.test.ts b/src/tests/cli/accumulator-usage.test.ts index 10c1753..15f5677 100644 --- a/src/tests/cli/accumulator-usage.test.ts +++ b/src/tests/cli/accumulator-usage.test.ts @@ -102,7 +102,6 @@ describe("accumulator usage statistics", () => { ); expect(tracker.pendingCompaction).toBe(true); - expect(tracker.pendingSkillsReinject).toBe(true); expect(tracker.pendingReflectionTrigger).toBe(true); }); @@ -126,7 +125,6 @@ describe("accumulator usage statistics", () => { ); expect(tracker.pendingCompaction).toBe(true); - expect(tracker.pendingSkillsReinject).toBe(true); expect(tracker.pendingReflectionTrigger).toBe(true); }); diff --git a/src/tests/cli/contextTracker.test.ts b/src/tests/cli/contextTracker.test.ts index d4af250..ba8b041 100644 --- a/src/tests/cli/contextTracker.test.ts +++ b/src/tests/cli/contextTracker.test.ts @@ -12,7 +12,6 @@ describe("contextTracker", () => { { timestamp: 1, tokens: 111, turnId: 1, compacted: true }, ]; tracker.pendingCompaction = true; - tracker.pendingSkillsReinject = true; tracker.pendingReflectionTrigger = true; tracker.currentTurnId = 9; @@ -21,7 +20,6 @@ describe("contextTracker", () => { expect(tracker.lastContextTokens).toBe(0); expect(tracker.contextTokensHistory).toEqual([]); expect(tracker.pendingCompaction).toBe(false); - expect(tracker.pendingSkillsReinject).toBe(false); expect(tracker.pendingReflectionTrigger).toBe(false); expect(tracker.currentTurnId).toBe(9); }); diff --git a/src/tests/headless/client-skills-wiring.test.ts b/src/tests/headless/client-skills-wiring.test.ts new file mode 100644 index 0000000..b051c5d --- /dev/null +++ b/src/tests/headless/client-skills-wiring.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +describe("headless client skills wiring", () => { + test("pre-load-skills resolves skill paths from client-skills helper", () => { + const headlessPath = fileURLToPath( + new URL("../../headless.ts", import.meta.url), + ); + const source = readFileSync(headlessPath, "utf-8"); + + expect(source).toContain("buildClientSkillsPayload({"); + expect(source).toContain( + "const { skillPathById } = await buildClientSkillsPayload", + ); + expect(source).toContain("const skillPath = skillPathById[skillId]"); + expect(source).not.toContain("sharedReminderState.skillPathById"); + }); +}); diff --git a/src/tests/reminders/skills-recovery.test.ts b/src/tests/reminders/skills-recovery.test.ts deleted file mode 100644 index f8d917e..0000000 --- a/src/tests/reminders/skills-recovery.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { SharedReminderContext } from "../../reminders/engine"; -import { sharedReminderProviders } from "../../reminders/engine"; -import { createSharedReminderState } from "../../reminders/state"; - -function buildContext(): SharedReminderContext { - return { - mode: "interactive", - agent: { - id: "agent-1", - name: "Agent 1", - description: null, - lastRunAt: null, - }, - state: createSharedReminderState(), - sessionContextReminderEnabled: true, - reflectionSettings: { - trigger: "off", - behavior: "reminder", - stepCount: 25, - }, - skillSources: ["bundled"], - resolvePlanModeReminder: () => "", - }; -} - -describe("shared skills reminder", () => { - test("recovers from discovery failure and reinjects after next successful discovery", async () => { - const provider = sharedReminderProviders.skills; - const context = buildContext(); - - const mutableProcess = process as typeof process & { cwd: () => string }; - const originalCwd = mutableProcess.cwd; - try { - mutableProcess.cwd = () => { - throw new Error("cwd unavailable for test"); - }; - - const first = await provider(context); - expect(first).toBeNull(); - expect(context.state.hasInjectedSkillsReminder).toBe(true); - expect(context.state.cachedSkillsReminder).toBe(""); - } finally { - mutableProcess.cwd = originalCwd; - } - - const second = await provider(context); - expect(second).not.toBeNull(); - expect(context.state.pendingSkillsReinject).toBe(false); - if (second) { - expect(second).toContain(""); - } - }); -}); diff --git a/src/tools/descriptions/Skill.md b/src/tools/descriptions/Skill.md index 0c11a8e..987e1f2 100644 --- a/src/tools/descriptions/Skill.md +++ b/src/tools/descriptions/Skill.md @@ -15,7 +15,7 @@ How to invoke: - `skill: "ms-office-suite:pdf"` - invoke using fully qualified name Important: -- Available skills are listed in system-reminder messages in the conversation +- Available skills are included in your current prompt context in the conversation - When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task - NEVER mention a skill without actually calling this tool - Do not invoke a skill that is already running