From 9b5a22b6b4ac3bbaafca660da7b81f57582d6245 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 7 Oct 2026 01:43:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=A1=8C=E9=9D=A2=E7=AB=AF@s?= =?UTF-8?q?kill=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/agent/agent.ts | 18 ++- packages/codingcode/src/agent/port.ts | 1 + packages/codingcode/src/agent/prompt.ts | 18 +++ packages/codingcode/src/layer.ts | 5 +- .../codingcode/src/server/routes/messages.ts | 8 +- .../codingcode/src/server/routes/skills.ts | 23 +-- packages/codingcode/src/skills/port.ts | 2 +- packages/codingcode/src/skills/skills.ts | 14 +- packages/codingcode/src/skills/source.ts | 10 +- packages/codingcode/src/subagent/registry.ts | 13 +- .../test/agent/render-skill-block.test.ts | 36 +++++ .../test/agent/skill-mention-persist.test.ts | 116 +++++++++++++++ .../codingcode/test/helpers/agent-harness.ts | 14 +- .../test/server/compact-route.test.ts | 2 +- packages/codingcode/test/server/index.test.ts | 2 +- .../test/server/plan-file-route.test.ts | 2 +- packages/codingcode/test/skills/index.test.ts | 51 +------ .../codingcode/test/subagent/registry.test.ts | 8 +- .../test/subagent/runner-wiring.test.ts | 3 +- packages/desktop/src/agent/AgentWorkspace.tsx | 137 +++++++++++++++++- packages/desktop/src/hooks/useAgent.ts | 3 +- packages/desktop/src/lib/core-api.ts | 1 - packages/desktop/src/settings/SkillPanel.tsx | 8 +- packages/sdk/src/contracts.ts | 9 +- packages/sdk/src/http/agent-runtime.ts | 4 +- 25 files changed, 396 insertions(+), 112 deletions(-) create mode 100644 packages/codingcode/test/agent/render-skill-block.test.ts create mode 100644 packages/codingcode/test/agent/skill-mention-persist.test.ts diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 1a6fad17..db05503e 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -17,7 +17,7 @@ import { SessionService } from '../session/port.js'; import { SkillService } from '../skills/port.js'; import { TodoService } from '../todo/port.js'; import { ToolExecutorService } from '../tools/port.js'; -import { buildSystemPrompt } from './prompt.js'; +import { buildSystemPrompt, renderSkillBlock } from './prompt.js'; import type { EndTransition, FrameBody, FrameError, ResponseMeta, ToolOutcome } from '../contracts/frame.js'; import { isTurnEnd } from '../contracts/frame.js'; import type { SessionRef } from '../contracts/session.js'; @@ -123,9 +123,19 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const toolEnv = yield* toolEnvPort.getToolEnv(); - // record user (increments turn) + extract skill - const [, actualInput] = yield* skills.extractSkill(state.cwd, input); - const turnId = (yield* session.recordUser(state, actualInput)).turnId; + const turnId = (yield* session.recordUser(state, input)).turnId; + + // 用户显式 @ 的 skill:按 path 回查权威数据,正文拼块后作为同回合的第二条 user 事件 + if (opts.skills?.length) { + const all = yield* skills.getAll(state.cwd); + const chosen = all.filter((s) => opts.skills!.some((m) => m.path === s.skillPath)); + if (chosen.length) { + const entries = yield* Effect.forEach(chosen, (s) => + skills.readContent(s.skillPath).pipe(Effect.map((body) => ({ skill: s, body }))) + ); + yield* session.recordSystem(state, renderSkillBlock(entries)); + } + } // checkpoint baseline yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId); diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts index fbb5930d..a03b912d 100644 --- a/packages/codingcode/src/agent/port.ts +++ b/packages/codingcode/src/agent/port.ts @@ -15,6 +15,7 @@ export interface RunTurnOptions { parentSessionId?: string; agentName?: string; systemPrompt?: string; + skills?: ReadonlyArray<{ name: string; path: string }>; } export interface AgentShape { diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts index 8be8c3b5..9c86eb58 100644 --- a/packages/codingcode/src/agent/prompt.ts +++ b/packages/codingcode/src/agent/prompt.ts @@ -1,4 +1,5 @@ import { BUILD_PROMPT } from './profile.js'; +import type { Skill } from '../contracts/skill.js'; import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js'; interface SystemPromptOptions { @@ -39,3 +40,20 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string { return prompt; } + +export interface SkillBlockEntry { + skill: Skill; + body: string; +} + +/** 用户显式 @ 的 skill 提示块;正文内嵌,path 供模型解析正文里的相对路径。 */ +export function renderSkillBlock(entries: ReadonlyArray): string { + if (entries.length === 0) return ''; + const blocks = entries + .map( + ({ skill, body }) => + `\n${skill.name}\n${skill.skillPath}\n${body}\n` + ) + .join('\n\n'); + return `The user explicitly referenced the following skill(s). Follow the instructions below.\n\n${blocks}`; +} diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index a7637ef3..4ceb8c3c 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -50,10 +50,11 @@ const AgentWithDeps = AgentLayer.pipe( // subagent runner (depends on agent) const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps)); -// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧。 +// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧、 +// 要 hooks 在子代理终态时触发 agent.subagent.complete。 // 不依赖 SessionLayer —— 它不写盘,写盘由父回合循环在 drain 点做。 const SubagentRunRegistryWithDeps = SubagentRunRegistryLayer.pipe( - Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer)) + Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer, HookLayer)) ); export const AppLayer = Layer.mergeAll( diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 27ad055b..d7ef63ff 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -15,7 +15,12 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/messages', async (c) => { let sessionId = c.req.param('id'); - const { input, cwd, model } = await c.req.json<{ input: string; cwd: string; model?: string }>(); + const { input, cwd, model, skills } = await c.req.json<{ + input: string; + cwd: string; + model?: string; + skills?: Array<{ name: string; path: string }>; + }>(); // 模型是回合的必要输入,缺失即拒绝,不允许在 agent 层兜底成空串 if (!model?.trim()) { return c.json(errorBody('CONFIG_MISSING', 'model is required'), 400); @@ -28,6 +33,7 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { cwd: normalizedCwd, signal: c.req.raw.signal, model, + skills, }; if (isNew) { // 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准 diff --git a/packages/codingcode/src/server/routes/skills.ts b/packages/codingcode/src/server/routes/skills.ts index 44716a6d..58db6820 100644 --- a/packages/codingcode/src/server/routes/skills.ts +++ b/packages/codingcode/src/server/routes/skills.ts @@ -1,8 +1,9 @@ import type { Hono } from 'hono'; +import { dirname } from 'path'; import { Effect, ManagedRuntime } from 'effect'; import { SkillService } from '../../skills/port.js'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; +import { discoverGlobalSkillDirs } from '../../skills/source.js'; import { createRunWithLayer } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -29,10 +30,7 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void ); } const cwd = resolveCwd(rawCwd); - const globalDirs = discoverGlobalSkillDirs(); - const projectDirs = discoverProjectSkillDirs(cwd); - const globalNames = new Set(globalDirs.map((d) => d.name)); - const projectNames = new Set(projectDirs.map((d) => d.name)); + const globalDirPaths = new Set(discoverGlobalSkillDirs().map((d) => d.dirPath)); const result = await runWithLayer( Effect.gen(function* () { const skill = yield* SkillService; @@ -40,17 +38,12 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void }) ); const skills = result.ok ? result.value : []; + // 按 SKILL.md 所在目录判定来源;同名同时存在于全局与项目时,两条各自标注真实来源 return c.json( - skills.map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) + skills.map((s) => ({ + ...s, + source: globalDirPaths.has(dirname(s.skillPath)) ? ('global' as const) : ('project' as const), + })) ); }); } diff --git a/packages/codingcode/src/skills/port.ts b/packages/codingcode/src/skills/port.ts index 5fbea5d8..c41a0b25 100644 --- a/packages/codingcode/src/skills/port.ts +++ b/packages/codingcode/src/skills/port.ts @@ -4,7 +4,7 @@ import type { Skill } from '../contracts/skill.js'; export interface SkillShape { getAll(projectPath: string): Effect.Effect; - extractSkill(projectPath: string, query: string): Effect.Effect<[Skill | undefined, string]>; + readContent(skillPath: string): Effect.Effect; } export class SkillService extends Context.Tag('Skill')() {} diff --git a/packages/codingcode/src/skills/skills.ts b/packages/codingcode/src/skills/skills.ts index 7cfe4ff5..69803879 100644 --- a/packages/codingcode/src/skills/skills.ts +++ b/packages/codingcode/src/skills/skills.ts @@ -1,5 +1,5 @@ import { Layer, Effect } from 'effect'; -import { discoverSkillDirs } from './source.js'; +import { discoverSkillDirs, readSkillBody } from './source.js'; import { loadSkill } from './loader.js'; import type { Skill } from '../contracts/skill.js'; import { SkillService } from './port.js'; @@ -23,16 +23,6 @@ export const SkillLayer = Layer.effect(SkillService, Effect.gen(function* () { return { getAll: (projectPath: string) => Effect.sync(() => readAll(projectPath)), - extractSkill: (projectPath: string, query: string) => - Effect.sync(() => { - const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/); - let skill: Skill | undefined; - if (match) { - const name = match[1]!; - skill = readAll(projectPath).find((s) => s.name === name); - } - const actualQuery = query.replace(/^@[a-zA-Z0-9-]+\s*/, ''); - return [skill, actualQuery] as [Skill | undefined, string]; - }), + readContent: (skillPath: string) => Effect.sync(() => readSkillBody(skillPath)), }; })); diff --git a/packages/codingcode/src/skills/source.ts b/packages/codingcode/src/skills/source.ts index 6f60c60f..ac275fc7 100644 --- a/packages/codingcode/src/skills/source.ts +++ b/packages/codingcode/src/skills/source.ts @@ -15,11 +15,15 @@ export interface SkillDirectory { name: string; } -/** 全局在前、项目在后:同名 skill 由后者覆盖(消费方按 name 去重,后写者胜)。 */ export function discoverSkillDirs(projectRoot: string): SkillDirectory[] { return [...discoverGlobalSkillDirs(), ...discoverProjectSkillDirs(projectRoot)]; } +/** 读取 SKILL.md 全文(含 front matter),供拼 skill 提示块使用。 */ +export function readSkillBody(skillPath: string): string { + return readFileSync(skillPath, 'utf8'); +} + /** Parse only the SKILL.md front matter used for skill discovery. */ export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null { const skillMdPath = join(dirPath, 'SKILL.md'); @@ -41,7 +45,7 @@ export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null { // ---- 辅助函数:分别获取全局/项目级 Skill 目录 ---- -/** `~/.codingcode/skills/` —— 先加载,同名的项目级 skill 会覆盖它 */ +/** `~/.codingcode/skills/` —— 全局 skill 目录 */ export function discoverGlobalSkillDirs(): SkillDirectory[] { const dirs: SkillDirectory[] = []; const globalSkillsDir = join(getGlobalDir(), 'skills'); @@ -56,7 +60,7 @@ export function discoverGlobalSkillDirs(): SkillDirectory[] { return dirs; } -/** `/.codingcode/skills/` —— 后加载,优先级更高 */ +/** `/.codingcode/skills/` —— 项目级 skill 目录 */ export function discoverProjectSkillDirs(projectRoot: string): SkillDirectory[] { const dirs: SkillDirectory[] = []; const projectSkillsDir = join(projectRoot, CODINGCODE_DIRNAME, 'skills'); diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 8766b799..47d75bfd 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -8,6 +8,7 @@ import { loadConfig } from '../infra/config.js'; import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js'; import { MailboxService } from '../session/mailbox.js'; import { EventSinkService } from '../sink/port.js'; +import { HookService } from '../hooks/port.js'; import { SubagentRunnerService } from './port.js'; export type SubagentRunStatus = @@ -46,6 +47,7 @@ export class SubagentRunRegistryService extends Context.Tag('SubagentRunRegistry interface SubagentRun { readonly sessionId: string; readonly parentSessionId: string; + readonly parentCwd: string; readonly agentName: string; readonly status: SubscriptionRef.SubscriptionRef; readonly abort: AbortController; @@ -116,6 +118,7 @@ export const SubagentRunRegistryLayer = Layer.scoped( const mailbox = yield* MailboxService; const runner = yield* SubagentRunnerService; const sink = yield* EventSinkService; + const hooks = yield* HookService; const runs = new Map(); // 键 = 子会话 sessionId;parentSessionId 只是条目上的字段 /** 帧直投父会话的出站队列:EventSink 的键就是收件人会话,不需要任何回调透传 */ @@ -159,6 +162,12 @@ export const SubagentRunRegistryLayer = Layer.scoped( run.parentSessionId, run.sessionId, run.agentName, outcome.end.reason === 'done' ? 'completed' : 'failed', ); + yield* hooks.emit('agent.subagent.complete', { + projectPath: run.parentCwd, + childSessionId: run.sessionId, + agentName: run.agentName, + status: outcome.end.reason === 'done' ? 'completed' : 'failed', + }); }); const spawn = (opts: SpawnOptions) => @@ -183,8 +192,8 @@ export const SubagentRunRegistryLayer = Layer.scoped( const status = yield* SubscriptionRef.make({ kind: 'running' }); const run: SubagentRun = { - sessionId, parentSessionId: opts.parentSessionId, agentName: opts.agentName, - status, abort, + sessionId, parentSessionId: opts.parentSessionId, parentCwd: opts.parentCwd, + agentName: opts.agentName, status, abort, }; runs.set(sessionId, run); yield* emitSubagent(opts.parentSessionId, sessionId, opts.agentName, 'spawned'); diff --git a/packages/codingcode/test/agent/render-skill-block.test.ts b/packages/codingcode/test/agent/render-skill-block.test.ts new file mode 100644 index 00000000..a56be7a4 --- /dev/null +++ b/packages/codingcode/test/agent/render-skill-block.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { renderSkillBlock } from '../../src/agent/prompt.js'; +import type { Skill } from '../../src/contracts/skill.js'; + +function skill(name: string, skillPath: string): Skill { + return { name, description: `${name} description`, skillPath }; +} + +describe('renderSkillBlock', () => { + it('produces nothing when no skill was selected', () => { + expect(renderSkillBlock([])).toBe(''); + }); + + it('carries the selected skill name, path and full body', () => { + const out = renderSkillBlock([ + { skill: skill('aaa-skill', '/tmp/aaa/SKILL.md'), body: 'body-of-aaa' }, + ]); + + expect(out).toContain('aaa-skill'); + expect(out).toContain('/tmp/aaa/SKILL.md'); + expect(out).toContain('body-of-aaa'); + }); + + it('renders every selected skill in the given order', () => { + const out = renderSkillBlock([ + { skill: skill('aaa-skill', '/tmp/aaa/SKILL.md'), body: 'body-of-aaa' }, + { skill: skill('bbb-skill', '/tmp/bbb/SKILL.md'), body: 'body-of-bbb' }, + ]); + + expect(out).toContain('aaa-skill'); + expect(out).toContain('body-of-aaa'); + expect(out).toContain('bbb-skill'); + expect(out).toContain('body-of-bbb'); + expect(out.indexOf('body-of-aaa')).toBeLessThan(out.indexOf('body-of-bbb')); + }); +}); diff --git a/packages/codingcode/test/agent/skill-mention-persist.test.ts b/packages/codingcode/test/agent/skill-mention-persist.test.ts new file mode 100644 index 00000000..cdc485ac --- /dev/null +++ b/packages/codingcode/test/agent/skill-mention-persist.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionLayer } from '../../src/session/session.js'; +import { SessionService } from '../../src/session/port.js'; +import { SkillLayer } from '../../src/skills/skills.js'; +import { SkillService } from '../../src/skills/port.js'; +import { normalizePath } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readHistory } from '../../src/session/file-ops.js'; +import type { UserEvent } from '../../src/contracts/session.js'; +import { runAgentTurn, llmStream, pText, pEnd } from '../helpers/agent-harness.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +const SKILL_NAME = 'release-notes'; +const SKILL_BODY = `--- +name: ${SKILL_NAME} +description: "Draft release notes" + +## Steps +1. Collect merged PRs +`; + +/** 取 agent 侧实际使用的权威 skillPath(与前端从列表拿到的路径同源)。 */ +async function canonicalSkillPath(cwd: string): Promise { + return Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const svc = yield* SkillService; + const all = yield* svc.getAll(normalizePath(cwd)); + return all.find((s) => s.name === SKILL_NAME)!.skillPath; + }), + SkillLayer + ) + ); +} + +describe('agent runTurn with an explicit @ skill', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = join(base.dir, randomUUID()); + const skillDir = join(projectDir, '.codingcode', 'skills', SKILL_NAME); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), SKILL_BODY); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + /** 真实 Session + 真实 Skill、mock LLM,跑一轮带 @ skill 的回合。 */ + async function runTurnWithSkill() { + const input = `please @${SKILL_NAME} before shipping`; + const skillPath = await canonicalSkillPath(projectDir); + + const { sessionId } = await runAgentTurn( + { + llm: { completeStream: () => llmStream(pText('ok'), pEnd()), modelInfo: { maxTokens: 1000 } }, + sessionLayer: SessionLayer, + skillLayer: SkillLayer, + }, + { + cwd: projectDir, + input, + activeProfile: 'build', + permissionMode: 'askBeforeExec', + skills: [{ name: SKILL_NAME, path: skillPath }], + } + ); + + return { sessionId, input, skillPath }; + } + + it('persists the untouched user text and the skill block as two events of one turn', async () => { + const { sessionId, input, skillPath } = await runTurnWithSkill(); + + const persisted = readHistory(computePaths(projectDir, sessionId).transcriptPath); + const userEvents = persisted.filter((e): e is UserEvent => e.type === 'user'); + + expect(userEvents).toHaveLength(2); + expect(userEvents[0]!.content).toBe(input); + expect(userEvents[0]!.source).toBe('user'); + + const block = userEvents[1]!; + expect(block.source).toBe('system'); + expect(block.turnId).toBe(userEvents[0]!.turnId); + expect(block.content).toContain(SKILL_NAME); + expect(block.content).toContain(skillPath); + expect(block.content).toContain('Collect merged PRs'); + }); + + it('does not surface the skill block in the UI history', async () => { + const { sessionId, input } = await runTurnWithSkill(); + + const turns = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.readUITurns(sessionId, projectDir); + }), + SessionLayer + ) + ); + + const userMessages = turns + .flatMap((t) => t.items) + .flatMap((i) => (i.type === 'message' && i.role === 'user' ? [i.content] : [])); + + expect(userMessages).toEqual([input]); + }); +}); diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index 03693e57..803dba30 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -148,6 +148,10 @@ export interface HarnessMocks { setPermissionMode: (cwd: string, sid: string, mode: any) => any; setActiveProfile: (cwd: string, sid: string, profile: any) => any; }>; + /** 可选:用真实 SessionLayer 替换默认 mock,用于需要真实落盘的端到端用例。 */ + sessionLayer?: Layer.Layer; + /** 可选:用真实 SkillLayer 替换默认空实现,用于需要真实读取 skill 文件的端到端用例。 */ + skillLayer?: Layer.Layer; } export function makeState(partial: Partial = {}): SessionStoreState { @@ -198,6 +202,8 @@ export interface RunAgentOptions { activeProfile?: 'plan' | 'build'; permissionMode?: string; model?: string; + /** 随请求下发的显式 @ skill;缺省时 agent 不应产生任何 skill 提示块。 */ + skills?: ReadonlyArray<{ name: string; path: string }>; } export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { @@ -240,7 +246,8 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { }; const skills = { - extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + getAll: () => Effect.succeed([]), + readContent: () => Effect.succeed(''), }; // 压缩帧由 context 自己经 sink 发出(agent 不再参与),mock 同样遵守这个归属 const ContextMockLayer = Layer.effect( @@ -276,7 +283,7 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { } as any); const services = Layer.mergeAll( - Layer.succeed(SessionService, session as any), + mocks.sessionLayer ?? Layer.succeed(SessionService, session as any), Layer.succeed(ToolExecutorService, executor as any), Layer.succeed(CheckpointService, { snapshotBaseline: () => Effect.void, @@ -285,7 +292,7 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { Layer.succeed(ApprovalService, { evaluate: () => Effect.succeed({ type: 'allow', source: 'test' }), } as any), - Layer.succeed(SkillService, skills as any), + mocks.skillLayer ?? Layer.succeed(SkillService, skills as any), ContextMockLayer, EventSinkLayer, MailboxLayer, @@ -355,6 +362,7 @@ export async function runAgentTurn( if (opts.signal) runOpts.signal = opts.signal; if (opts.activeProfile) runOpts.activeProfile = opts.activeProfile; if (opts.permissionMode) runOpts.permissionMode = opts.permissionMode; + if (opts.skills) runOpts.skills = opts.skills; return yield* agent.runTurn(opts.input ?? 'test', runOpts); }); let runRes: { stream: AsyncGenerator; sessionId: string }; diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index 8df7d8cf..8d5de496 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -58,7 +58,7 @@ const MockApprovalLayer = ApprovalLayer.pipe( const MockSkillLayer = Layer.succeed(SkillService, { _tag: 'Skill' as const, getAll: () => Effect.succeed([]), - extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), + readContent: () => Effect.succeed(''), } as any); const MockMcpLayer = Layer.succeed(McpService, { diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 63ee5320..1716e3b3 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -49,7 +49,7 @@ const MockApprovalLayer = ApprovalLayer.pipe( const MockSkillLayer = Layer.succeed(SkillService, { _tag: 'Skill' as const, getAll: () => Effect.succeed([]), - extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), + readContent: () => Effect.succeed(''), } as any); const MockMcpLayer = Layer.succeed(McpService, { diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index 61ca23dd..ce1ac554 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -68,7 +68,7 @@ const MockApprovalLayer = ApprovalLayer.pipe( const MockSkillLayer = Layer.succeed(SkillService, { _tag: 'Skill' as const, getAll: () => Effect.succeed([]), - extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), + readContent: () => Effect.succeed(''), } as any); const MockMcpLayer = Layer.succeed(McpService, { diff --git a/packages/codingcode/test/skills/index.test.ts b/packages/codingcode/test/skills/index.test.ts index 4d012a47..43333745 100644 --- a/packages/codingcode/test/skills/index.test.ts +++ b/packages/codingcode/test/skills/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { Context, Effect, Layer } from 'effect'; @@ -71,14 +71,6 @@ Test the skill system. } }); - afterAll(() => { - try { - if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); - } catch { - /* temp dir cleanup is best-effort */ - } - }); - it('should load skills from .codingcode/skills/ on demand', () => { const skills = runWithSkill((s) => s.getAll(TEST_ROOT)); expect(skills.length).toBeGreaterThanOrEqual(1); @@ -140,43 +132,12 @@ Dynamic skill body. expect((after as any[]).length).toBe((before as any[]).length); }); - it('should extract skill and return clean query', () => { - const [matched, cleanQuery] = runWithSkill((s) => - s.extractSkill(TEST_ROOT, '@test-basic do the refactoring work') - ); - expect(matched).toBeDefined(); - expect(matched!.name).toBe('test-basic'); - expect(cleanQuery).toBe('do the refactoring work'); - }); - - it('should support kebab-case skill names in @ prefix', () => { - const dir = join(TEST_CODINGCODE_DIR, 'skills', 'my-kebab-skill'); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, 'SKILL.md'), - `--- -name: my-kebab-skill -description: "Kebab case test" ---- -## Kebab -Testing kebab-case name parsing. -` - ); - const [matched] = runWithSkill((s) => s.extractSkill(TEST_ROOT, '@my-kebab-skill run tests')); - expect(matched).toBeDefined(); - expect(matched!.name).toBe('my-kebab-skill'); - }); + it('readContent returns the SKILL.md text verbatim, front matter included', () => { + const basic = runWithSkill((s) => s.getAll(TEST_ROOT)).find((s) => s.name === 'test-basic')!; - it('should return undefined skill when @ prefix does not match any skill', () => { - const [matched] = runWithSkill((s) => s.extractSkill(TEST_ROOT, '@nonexistent do something')); - expect(matched).toBeUndefined(); - }); + const body = runWithSkill((s) => s.readContent(basic.skillPath)); - it('should return undefined skill and keep query when no @ prefix', () => { - const [matched, cleanQuery] = runWithSkill((s) => - s.extractSkill(TEST_ROOT, 'just a normal message') - ); - expect(matched).toBeUndefined(); - expect(cleanQuery).toBe('just a normal message'); + expect(body.startsWith('---')).toBe(true); + expect(body).toContain('Test the skill system.'); }); }); diff --git a/packages/codingcode/test/subagent/registry.test.ts b/packages/codingcode/test/subagent/registry.test.ts index fce8e5f1..e20340c9 100644 --- a/packages/codingcode/test/subagent/registry.test.ts +++ b/packages/codingcode/test/subagent/registry.test.ts @@ -4,6 +4,7 @@ import { SubagentRunRegistryLayer, SubagentRunRegistryService } from '../../src/ import { SubagentRunnerService } from '../../src/subagent/port.js'; import { MailboxLayer, MailboxService } from '../../src/session/mailbox.js'; import { EventSinkService } from '../../src/sink/port.js'; +import { HookService } from '../../src/hooks/port.js'; import type { FrameBody } from '../../src/contracts/frame.js'; const spawnOpts = { @@ -61,10 +62,15 @@ function makeHarness(makeStream: () => AsyncGenerator) { emitted.push({ sessionId, body }); }), } as any); + const hooks = Layer.succeed(HookService, { + emit: () => Effect.void, + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.void, + } as any); // provideMerge:MailboxService 既注入注册表,也保留在输出里供断言使用 // (同一次 Layer 构建 ⇒ 同一实例) const layers = SubagentRunRegistryLayer.pipe( - Layer.provideMerge(Layer.mergeAll(runner, MailboxLayer, sink)) + Layer.provideMerge(Layer.mergeAll(runner, MailboxLayer, sink, hooks)) ); return { emitted, signals, layers }; } diff --git a/packages/codingcode/test/subagent/runner-wiring.test.ts b/packages/codingcode/test/subagent/runner-wiring.test.ts index c93dbbcd..75b23314 100644 --- a/packages/codingcode/test/subagent/runner-wiring.test.ts +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -131,7 +131,8 @@ const AgentDeps = Layer.mergeAll( evaluate: () => Effect.succeed({ type: 'allow' }), } as any), Layer.succeed(SkillService, { - extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + getAll: () => Effect.succeed([]), + readContent: () => Effect.succeed(''), } as any), Layer.succeed(ContextService, { getHistory: (ref: SessionRef) => Effect.sync(() => readMessages(transcriptPathFor(ref))), diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 54be5fb2..336a3405 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -9,6 +9,7 @@ import { setSessionPermissionMode, setAgentConfig, stopAllSubagents, + listSkills, } from '../lib/core-api'; import MessageStream from './MessageStream'; import TodoPanel from './TodoPanel'; @@ -217,6 +218,16 @@ function ModelSelector() { // ─── InputBox ────────────────────────────────────────────────────────────── +interface SkillOption { + name: string; + skillPath: string; +} + +interface SkillRef { + name: string; + path: string; +} + function InputBox({ centered, sendMessage, @@ -224,7 +235,7 @@ function InputBox({ onOpenPlanPanel, }: { centered?: boolean; - sendMessage: (content: string, cwd?: string) => Promise; + sendMessage: (content: string, cwd?: string, skills?: SkillRef[]) => Promise; abort: () => void; onOpenPlanPanel?: () => void; }) { @@ -246,8 +257,6 @@ function InputBox({ const stopButtonRef = useRef(null); const stopMenuRef = useRef(null); - // 输入框容器带 overflow-hidden(裁圆角),绝对定位的下拉会被裁掉。 - // 与 ModelSelector 同法:portal 到 body + fixed 定位,绕开祖先裁剪。 useLayoutEffect(() => { if (stopMenuOpen && stopButtonRef.current && stopMenuRef.current) { const rect = stopButtonRef.current.getBoundingClientRect(); @@ -256,6 +265,27 @@ function InputBox({ } }, [stopMenuOpen]); + const [skillOptions, setSkillOptions] = useState([]); + const [skillMenu, setSkillMenu] = useState<{ query: string; start: number } | null>(null); + const [skillIndex, setSkillIndex] = useState(0); + const skillMenuRef = useRef(null); + const [pickedPaths, setPickedPaths] = useState>({}); + + useEffect(() => { + if (!workspace.rootPath) return; + listSkills(workspace.rootPath) + .then((data) => setSkillOptions(data ?? [])) + .catch(() => setSkillOptions([])); + }, [workspace.rootPath]); + + useLayoutEffect(() => { + if (skillMenu && textareaRef.current && skillMenuRef.current) { + const rect = textareaRef.current.getBoundingClientRect(); + skillMenuRef.current.style.bottom = `${window.innerHeight - rect.top + 4}px`; + skillMenuRef.current.style.left = `${rect.left + 12}px`; + } + }, [skillMenu, skillIndex]); + /** 「停止全部」:先让服务端 abort 所有后台子代理,再停掉当前这条流 */ const handleStopAll = useCallback(async () => { setStopMenuOpen(false); @@ -297,12 +327,49 @@ function InputBox({ } }, [pendingInput, setPendingInput]); + const handleChange = (value: string, caret: number) => { + setText(value); + const m = value.slice(0, caret).match(/(?:^|\s)@([a-zA-Z0-9-]*)$/); + if (m) { + setSkillMenu({ query: m[1]!, start: caret - m[1]!.length - 1 }); + setSkillIndex(0); + } else { + setSkillMenu(null); + } + }; + + const candidates = skillMenu + ? skillOptions.filter((s) => s.name.startsWith(skillMenu.query)) + : []; + + const confirmSkill = (option: SkillOption) => { + if (!skillMenu) return; + const rest = text.slice(skillMenu.start + 1 + skillMenu.query.length); + setText(`${text.slice(0, skillMenu.start)}@${option.name} ${rest}`); + setPickedPaths((prev) => ({ ...prev, [option.name]: option.skillPath })); + setSkillMenu(null); + textareaRef.current?.focus(); + }; + const handleSend = useCallback(() => { const trimmed = text.trim(); if (!trimmed || isStreaming) return; + const skills: SkillRef[] = []; + const seen = new Set(); + for (const m of trimmed.matchAll(/@([a-zA-Z0-9-]+)/g)) { + const name = m[1]!; + if (seen.has(name)) continue; + seen.add(name); + const hits = skillOptions.filter((s) => s.name === name); + // 下拉点选过就用那一条;手打则要求名字唯一,同名一律不下发 + const path = pickedPaths[name] ?? (hits.length === 1 ? hits[0]!.skillPath : undefined); + if (path) skills.push({ name, path }); + } setText(''); - sendMessage(trimmed, workspace.rootPath || undefined); - }, [text, isStreaming, sendMessage, workspace.rootPath]); + setPickedPaths({}); + setSkillMenu(null); + sendMessage(trimmed, workspace.rootPath || undefined, skills); + }, [text, isStreaming, sendMessage, workspace.rootPath, pickedPaths, skillOptions]); return ( @@ -313,8 +380,31 @@ function InputBox({