@moxxy/core 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/events/factory.d.ts +6 -0
- package/dist/events/factory.d.ts.map +1 -0
- package/dist/events/factory.js +14 -0
- package/dist/events/factory.js.map +1 -0
- package/dist/events/log.d.ts +92 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/log.js +244 -0
- package/dist/events/log.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions/engine.d.ts +94 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/engine.js +203 -0
- package/dist/permissions/engine.js.map +1 -0
- package/dist/permissions/resolvers.d.ts +8 -0
- package/dist/permissions/resolvers.d.ts.map +1 -0
- package/dist/permissions/resolvers.js +8 -0
- package/dist/permissions/resolvers.js.map +1 -0
- package/dist/plugins/discovery.d.ts +9 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +112 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/host-options.d.ts +72 -0
- package/dist/plugins/host-options.d.ts.map +1 -0
- package/dist/plugins/host-options.js +2 -0
- package/dist/plugins/host-options.js.map +1 -0
- package/dist/plugins/host.d.ts +53 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +269 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/lifecycle.d.ts +31 -0
- package/dist/plugins/lifecycle.d.ts.map +1 -0
- package/dist/plugins/lifecycle.js +100 -0
- package/dist/plugins/lifecycle.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +68 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/package-requirements.d.ts +16 -0
- package/dist/plugins/package-requirements.d.ts.map +1 -0
- package/dist/plugins/package-requirements.js +52 -0
- package/dist/plugins/package-requirements.js.map +1 -0
- package/dist/plugins/registry-kinds.d.ts +58 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -0
- package/dist/plugins/registry-kinds.js +139 -0
- package/dist/plugins/registry-kinds.js.map +1 -0
- package/dist/plugins/toposort.d.ts +24 -0
- package/dist/plugins/toposort.d.ts.map +1 -0
- package/dist/plugins/toposort.js +67 -0
- package/dist/plugins/toposort.js.map +1 -0
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +58 -0
- package/dist/preferences.js.map +1 -0
- package/dist/registries/active-backend-registry.d.ts +65 -0
- package/dist/registries/active-backend-registry.d.ts.map +1 -0
- package/dist/registries/active-backend-registry.js +108 -0
- package/dist/registries/active-backend-registry.js.map +1 -0
- package/dist/registries/active-def-registry.d.ts +59 -0
- package/dist/registries/active-def-registry.d.ts.map +1 -0
- package/dist/registries/active-def-registry.js +63 -0
- package/dist/registries/active-def-registry.js.map +1 -0
- package/dist/registries/agents.d.ts +16 -0
- package/dist/registries/agents.d.ts.map +1 -0
- package/dist/registries/agents.js +17 -0
- package/dist/registries/agents.js.map +1 -0
- package/dist/registries/cache-strategies.d.ts +12 -0
- package/dist/registries/cache-strategies.d.ts.map +1 -0
- package/dist/registries/cache-strategies.js +13 -0
- package/dist/registries/cache-strategies.js.map +1 -0
- package/dist/registries/channels.d.ts +16 -0
- package/dist/registries/channels.d.ts.map +1 -0
- package/dist/registries/channels.js +35 -0
- package/dist/registries/channels.js.map +1 -0
- package/dist/registries/commands.d.ts +29 -0
- package/dist/registries/commands.d.ts.map +1 -0
- package/dist/registries/commands.js +72 -0
- package/dist/registries/commands.js.map +1 -0
- package/dist/registries/compactors.d.ts +12 -0
- package/dist/registries/compactors.d.ts.map +1 -0
- package/dist/registries/compactors.js +13 -0
- package/dist/registries/compactors.js.map +1 -0
- package/dist/registries/def-map-registry.d.ts +33 -0
- package/dist/registries/def-map-registry.d.ts.map +1 -0
- package/dist/registries/def-map-registry.js +36 -0
- package/dist/registries/def-map-registry.js.map +1 -0
- package/dist/registries/embedders.d.ts +16 -0
- package/dist/registries/embedders.d.ts.map +1 -0
- package/dist/registries/embedders.js +17 -0
- package/dist/registries/embedders.js.map +1 -0
- package/dist/registries/isolators.d.ts +21 -0
- package/dist/registries/isolators.d.ts.map +1 -0
- package/dist/registries/isolators.js +32 -0
- package/dist/registries/isolators.js.map +1 -0
- package/dist/registries/modes.d.ts +28 -0
- package/dist/registries/modes.d.ts.map +1 -0
- package/dist/registries/modes.js +81 -0
- package/dist/registries/modes.js.map +1 -0
- package/dist/registries/providers.d.ts +44 -0
- package/dist/registries/providers.d.ts.map +1 -0
- package/dist/registries/providers.js +95 -0
- package/dist/registries/providers.js.map +1 -0
- package/dist/registries/skills.d.ts +24 -0
- package/dist/registries/skills.d.ts.map +1 -0
- package/dist/registries/skills.js +81 -0
- package/dist/registries/skills.js.map +1 -0
- package/dist/registries/surfaces.d.ts +13 -0
- package/dist/registries/surfaces.d.ts.map +1 -0
- package/dist/registries/surfaces.js +14 -0
- package/dist/registries/surfaces.js.map +1 -0
- package/dist/registries/synthesizers.d.ts +26 -0
- package/dist/registries/synthesizers.d.ts.map +1 -0
- package/dist/registries/synthesizers.js +34 -0
- package/dist/registries/synthesizers.js.map +1 -0
- package/dist/registries/tools.d.ts +49 -0
- package/dist/registries/tools.d.ts.map +1 -0
- package/dist/registries/tools.js +98 -0
- package/dist/registries/tools.js.map +1 -0
- package/dist/registries/transcribers.d.ts +17 -0
- package/dist/registries/transcribers.d.ts.map +1 -0
- package/dist/registries/transcribers.js +18 -0
- package/dist/registries/transcribers.js.map +1 -0
- package/dist/registries/tunnel-providers.d.ts +12 -0
- package/dist/registries/tunnel-providers.d.ts.map +1 -0
- package/dist/registries/tunnel-providers.js +13 -0
- package/dist/registries/tunnel-providers.js.map +1 -0
- package/dist/registries/view-renderers.d.ts +13 -0
- package/dist/registries/view-renderers.d.ts.map +1 -0
- package/dist/registries/view-renderers.js +14 -0
- package/dist/registries/view-renderers.js.map +1 -0
- package/dist/registries/workflow-executors.d.ts +13 -0
- package/dist/registries/workflow-executors.d.ts.map +1 -0
- package/dist/registries/workflow-executors.js +14 -0
- package/dist/registries/workflow-executors.js.map +1 -0
- package/dist/requirements.d.ts +59 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +149 -0
- package/dist/requirements.js.map +1 -0
- package/dist/run-turn.d.ts +6 -0
- package/dist/run-turn.d.ts.map +1 -0
- package/dist/run-turn.js +141 -0
- package/dist/run-turn.js.map +1 -0
- package/dist/session-runtime.d.ts +67 -0
- package/dist/session-runtime.d.ts.map +1 -0
- package/dist/session-runtime.js +2 -0
- package/dist/session-runtime.js.map +1 -0
- package/dist/session.d.ts +203 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +399 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions/persistence.d.ts +184 -0
- package/dist/sessions/persistence.d.ts.map +1 -0
- package/dist/sessions/persistence.js +442 -0
- package/dist/sessions/persistence.js.map +1 -0
- package/dist/setup-agent.d.ts +91 -0
- package/dist/setup-agent.d.ts.map +1 -0
- package/dist/setup-agent.js +138 -0
- package/dist/setup-agent.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +99 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parse.d.ts +5 -0
- package/dist/skills/parse.d.ts.map +1 -0
- package/dist/skills/parse.js +8 -0
- package/dist/skills/parse.js.map +1 -0
- package/dist/skills/synthesize-draft.d.ts +8 -0
- package/dist/skills/synthesize-draft.d.ts.map +1 -0
- package/dist/skills/synthesize-draft.js +32 -0
- package/dist/skills/synthesize-draft.js.map +1 -0
- package/dist/skills/synthesize.d.ts +33 -0
- package/dist/skills/synthesize.d.ts.map +1 -0
- package/dist/skills/synthesize.js +241 -0
- package/dist/skills/synthesize.js.map +1 -0
- package/dist/subagents/events.d.ts +20 -0
- package/dist/subagents/events.d.ts.map +1 -0
- package/dist/subagents/events.js +146 -0
- package/dist/subagents/events.js.map +1 -0
- package/dist/subagents/registry.d.ts +40 -0
- package/dist/subagents/registry.d.ts.map +1 -0
- package/dist/subagents/registry.js +42 -0
- package/dist/subagents/registry.js.map +1 -0
- package/dist/subagents/run-child.d.ts +43 -0
- package/dist/subagents/run-child.d.ts.map +1 -0
- package/dist/subagents/run-child.js +276 -0
- package/dist/subagents/run-child.js.map +1 -0
- package/dist/subagents/spawn.d.ts +8 -0
- package/dist/subagents/spawn.d.ts.map +1 -0
- package/dist/subagents/spawn.js +8 -0
- package/dist/subagents/spawn.js.map +1 -0
- package/dist/subagents/tools.d.ts +4 -0
- package/dist/subagents/tools.d.ts.map +1 -0
- package/dist/subagents/tools.js +13 -0
- package/dist/subagents/tools.js.map +1 -0
- package/dist/subagents.d.ts +3 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +3 -0
- package/dist/subagents.js.map +1 -0
- package/dist/surfaces/host.d.ts +53 -0
- package/dist/surfaces/host.d.ts.map +1 -0
- package/dist/surfaces/host.js +177 -0
- package/dist/surfaces/host.js.map +1 -0
- package/dist/tunnel/localhost.d.ts +7 -0
- package/dist/tunnel/localhost.d.ts.map +1 -0
- package/dist/tunnel/localhost.js +21 -0
- package/dist/tunnel/localhost.js.map +1 -0
- package/dist/usage-stats.d.ts +42 -0
- package/dist/usage-stats.d.ts.map +1 -0
- package/dist/usage-stats.js +83 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/view/default-renderer.d.ts +7 -0
- package/dist/view/default-renderer.d.ts.map +1 -0
- package/dist/view/default-renderer.js +14 -0
- package/dist/view/default-renderer.js.map +1 -0
- package/dist/view/parse.d.ts +7 -0
- package/dist/view/parse.d.ts.map +1 -0
- package/dist/view/parse.js +479 -0
- package/dist/view/parse.js.map +1 -0
- package/package.json +61 -0
- package/src/events/factory.ts +27 -0
- package/src/events/log.test.ts +470 -0
- package/src/events/log.ts +256 -0
- package/src/index.ts +102 -0
- package/src/logger.ts +46 -0
- package/src/permissions/engine.test.ts +206 -0
- package/src/permissions/engine.ts +247 -0
- package/src/permissions/resolvers.test.ts +40 -0
- package/src/permissions/resolvers.ts +17 -0
- package/src/plugins/discovery.test.ts +115 -0
- package/src/plugins/discovery.ts +124 -0
- package/src/plugins/host-options.ts +73 -0
- package/src/plugins/host-view-tunnel.test.ts +86 -0
- package/src/plugins/host.test.ts +434 -0
- package/src/plugins/host.ts +327 -0
- package/src/plugins/lifecycle.test.ts +187 -0
- package/src/plugins/lifecycle.ts +136 -0
- package/src/plugins/loader.ts +79 -0
- package/src/plugins/package-requirements.ts +50 -0
- package/src/plugins/registry-kinds.test.ts +162 -0
- package/src/plugins/registry-kinds.ts +210 -0
- package/src/plugins/toposort.test.ts +66 -0
- package/src/plugins/toposort.ts +69 -0
- package/src/preferences.test.ts +105 -0
- package/src/preferences.ts +84 -0
- package/src/registries/active-backend-registry.ts +142 -0
- package/src/registries/active-def-registry.test.ts +103 -0
- package/src/registries/active-def-registry.ts +97 -0
- package/src/registries/agents.ts +18 -0
- package/src/registries/cache-strategies.ts +14 -0
- package/src/registries/channels.ts +42 -0
- package/src/registries/commands.test.ts +71 -0
- package/src/registries/commands.ts +78 -0
- package/src/registries/compactors.ts +14 -0
- package/src/registries/def-map-registry.test.ts +67 -0
- package/src/registries/def-map-registry.ts +59 -0
- package/src/registries/embedders.test.ts +54 -0
- package/src/registries/embedders.ts +18 -0
- package/src/registries/isolators.test.ts +43 -0
- package/src/registries/isolators.ts +38 -0
- package/src/registries/modes.test.ts +66 -0
- package/src/registries/modes.ts +80 -0
- package/src/registries/providers.test.ts +50 -0
- package/src/registries/providers.ts +97 -0
- package/src/registries/semantics.test.ts +117 -0
- package/src/registries/skills.ts +87 -0
- package/src/registries/surfaces.ts +18 -0
- package/src/registries/synthesizers.test.ts +63 -0
- package/src/registries/synthesizers.ts +36 -0
- package/src/registries/tools.test.ts +142 -0
- package/src/registries/tools.ts +145 -0
- package/src/registries/transcribers.test.ts +70 -0
- package/src/registries/transcribers.ts +19 -0
- package/src/registries/tunnel-providers.test.ts +30 -0
- package/src/registries/tunnel-providers.ts +14 -0
- package/src/registries/view-renderers.test.ts +68 -0
- package/src/registries/view-renderers.ts +15 -0
- package/src/registries/workflow-executors.test.ts +53 -0
- package/src/registries/workflow-executors.ts +15 -0
- package/src/requirements.targetinfo.test.ts +70 -0
- package/src/requirements.test.ts +111 -0
- package/src/requirements.ts +262 -0
- package/src/run-turn.test.ts +178 -0
- package/src/run-turn.ts +156 -0
- package/src/session-runtime.ts +75 -0
- package/src/session.test.ts +216 -0
- package/src/session.ts +491 -0
- package/src/sessions/persistence.flush.test.ts +100 -0
- package/src/sessions/persistence.ready.test.ts +91 -0
- package/src/sessions/persistence.test.ts +274 -0
- package/src/sessions/persistence.ts +498 -0
- package/src/setup-agent.test.ts +72 -0
- package/src/setup-agent.ts +189 -0
- package/src/skills/index.ts +14 -0
- package/src/skills/loader.test.ts +102 -0
- package/src/skills/loader.ts +114 -0
- package/src/skills/parse.test.ts +59 -0
- package/src/skills/parse.ts +11 -0
- package/src/skills/synthesize-draft.test.ts +79 -0
- package/src/skills/synthesize-draft.ts +46 -0
- package/src/skills/synthesize.test.ts +170 -0
- package/src/skills/synthesize.ts +304 -0
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +194 -0
- package/src/subagents/registry.ts +62 -0
- package/src/subagents/run-child.test.ts +185 -0
- package/src/subagents/run-child.ts +425 -0
- package/src/subagents/spawn.ts +12 -0
- package/src/subagents/tools.ts +19 -0
- package/src/subagents.ts +2 -0
- package/src/surfaces/host.test.ts +330 -0
- package/src/surfaces/host.ts +192 -0
- package/src/tunnel/localhost.test.ts +22 -0
- package/src/tunnel/localhost.ts +21 -0
- package/src/usage-stats.test.ts +77 -0
- package/src/usage-stats.ts +119 -0
- package/src/view/default-renderer.test.ts +29 -0
- package/src/view/default-renderer.ts +14 -0
- package/src/view/parse-extended.test.ts +255 -0
- package/src/view/parse.test.ts +232 -0
- package/src/view/parse.ts +520 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import type { LLMProvider, ProviderEvent } from '@moxxy/sdk';
|
|
6
|
+
import { Session } from '../session.js';
|
|
7
|
+
import { asSkillId, asTurnId, defineProvider, definePlugin } from '@moxxy/sdk';
|
|
8
|
+
import { synthesizeSkill, buildSynthesizeSkillPlugin } from './synthesize.js';
|
|
9
|
+
|
|
10
|
+
const DRAFT_DELTA =
|
|
11
|
+
'---\nname: refactor-component\ndescription: Split a large React component.\ntriggers: ["refactor", "split"]\nallowed-tools: [Read, Edit]\n---\n# Steps\n\n1. Read the file.\n2. Identify boundaries.\n3. Edit into pieces.\n';
|
|
12
|
+
|
|
13
|
+
class InlineProvider implements LLMProvider {
|
|
14
|
+
readonly name = 'inline';
|
|
15
|
+
readonly models = [{ id: 'inline-1', contextWindow: 100_000, maxOutputTokens: 2000, supportsTools: true, supportsStreaming: true }];
|
|
16
|
+
private cursor = 0;
|
|
17
|
+
constructor(private readonly scripts: ReadonlyArray<ReadonlyArray<ProviderEvent>>) {}
|
|
18
|
+
async *stream(): AsyncIterable<ProviderEvent> {
|
|
19
|
+
const reply = this.scripts[this.cursor++];
|
|
20
|
+
if (!reply) throw new Error('InlineProvider: no script left');
|
|
21
|
+
for (const e of reply) yield e;
|
|
22
|
+
}
|
|
23
|
+
async countTokens(): Promise<number> { return 0; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const draftReply = (): ReadonlyArray<ProviderEvent> => [
|
|
27
|
+
{ type: 'message_start', model: 'inline-1' },
|
|
28
|
+
{ type: 'text_delta', delta: DRAFT_DELTA },
|
|
29
|
+
{ type: 'message_end', stopReason: 'end_turn' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
let tmp: string;
|
|
33
|
+
|
|
34
|
+
const newSessionWithProvider = (provider: InlineProvider) => {
|
|
35
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
36
|
+
const providerPlugin = definePlugin({
|
|
37
|
+
name: 'shim',
|
|
38
|
+
providers: [
|
|
39
|
+
defineProvider({ name: provider.name, models: [...provider.models], createClient: () => provider }),
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
session.pluginHost.registerStatic(providerPlugin);
|
|
43
|
+
session.providers.setActive(provider.name);
|
|
44
|
+
return session;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-synth-'));
|
|
49
|
+
});
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('synthesizeSkill', () => {
|
|
55
|
+
it('drafts a skill, writes it, registers, and emits SkillCreatedEvent', async () => {
|
|
56
|
+
const provider = new InlineProvider([draftReply()]);
|
|
57
|
+
const session = newSessionWithProvider(provider);
|
|
58
|
+
|
|
59
|
+
const result = await synthesizeSkill(session, 'split component up', 'user', {
|
|
60
|
+
userDir: path.join(tmp, 'skills'),
|
|
61
|
+
auditPath: path.join(tmp, 'audit.jsonl'),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result.scope).toBe('user');
|
|
65
|
+
expect(result.skill.frontmatter.name).toBe('refactor-component');
|
|
66
|
+
expect(result.path).toContain(path.join(tmp, 'skills'));
|
|
67
|
+
|
|
68
|
+
const written = await fs.readFile(result.path, 'utf8');
|
|
69
|
+
expect(written).toContain('refactor-component');
|
|
70
|
+
|
|
71
|
+
expect(session.skills.byName('refactor-component')).toBeDefined();
|
|
72
|
+
|
|
73
|
+
const createdEvents = session.log.ofType('skill_created');
|
|
74
|
+
expect(createdEvents).toHaveLength(1);
|
|
75
|
+
expect(createdEvents[0].name).toBe('refactor-component');
|
|
76
|
+
|
|
77
|
+
const audit = await fs.readFile(path.join(tmp, 'audit.jsonl'), 'utf8');
|
|
78
|
+
expect(audit).toContain('refactor-component');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('stamps skill_created with the active turnId, not a fresh orphan turn', async () => {
|
|
82
|
+
const provider = new InlineProvider([draftReply()]);
|
|
83
|
+
const session = newSessionWithProvider(provider);
|
|
84
|
+
const turnId = asTurnId('active-turn-1');
|
|
85
|
+
|
|
86
|
+
await synthesizeSkill(
|
|
87
|
+
session,
|
|
88
|
+
'split component up',
|
|
89
|
+
'user',
|
|
90
|
+
{ userDir: path.join(tmp, 'skills'), auditPath: path.join(tmp, 'audit.jsonl') },
|
|
91
|
+
turnId,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const createdEvents = session.log.ofType('skill_created');
|
|
95
|
+
expect(createdEvents).toHaveLength(1);
|
|
96
|
+
// Must carry the active turn's id so run-turn's per-turn subscriber filter
|
|
97
|
+
// (event.turnId !== turnId → drop) doesn't discard it. A fresh startTurn()
|
|
98
|
+
// id would never match the running turn.
|
|
99
|
+
expect(createdEvents[0].turnId).toBe(turnId);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('uses project dir when scope=project', async () => {
|
|
103
|
+
const provider = new InlineProvider([draftReply()]);
|
|
104
|
+
const session = newSessionWithProvider(provider);
|
|
105
|
+
|
|
106
|
+
const result = await synthesizeSkill(session, 'do x', 'project', {
|
|
107
|
+
projectDir: path.join(tmp, 'project/.moxxy/skills'),
|
|
108
|
+
auditPath: path.join(tmp, 'audit.jsonl'),
|
|
109
|
+
});
|
|
110
|
+
expect(result.scope).toBe('project');
|
|
111
|
+
expect(result.path).toContain(path.join(tmp, 'project/.moxxy/skills'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('avoids overwriting existing files with -2 suffix', async () => {
|
|
115
|
+
const provider = new InlineProvider([draftReply(), draftReply()]);
|
|
116
|
+
const session = newSessionWithProvider(provider);
|
|
117
|
+
|
|
118
|
+
const a = await synthesizeSkill(session, 'first', 'user', {
|
|
119
|
+
userDir: path.join(tmp, 'skills'),
|
|
120
|
+
auditPath: path.join(tmp, 'audit.jsonl'),
|
|
121
|
+
});
|
|
122
|
+
const b = await synthesizeSkill(session, 'second', 'user', {
|
|
123
|
+
userDir: path.join(tmp, 'skills'),
|
|
124
|
+
auditPath: path.join(tmp, 'audit.jsonl'),
|
|
125
|
+
});
|
|
126
|
+
expect(b.path).not.toBe(a.path);
|
|
127
|
+
expect(b.path).toMatch(/-2\.md$/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('buildSynthesizeSkillPlugin', () => {
|
|
132
|
+
it('registers synthesize_skill and reload_skills tools', () => {
|
|
133
|
+
const provider = new InlineProvider([]);
|
|
134
|
+
const session = newSessionWithProvider(provider);
|
|
135
|
+
const plugin = buildSynthesizeSkillPlugin(session);
|
|
136
|
+
session.pluginHost.registerStatic(plugin);
|
|
137
|
+
expect(session.tools.has('synthesize_skill')).toBe(true);
|
|
138
|
+
expect(session.tools.has('reload_skills')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('load_skill emits skill_invoked with the active turnId from ctx', async () => {
|
|
142
|
+
const provider = new InlineProvider([]);
|
|
143
|
+
const session = newSessionWithProvider(provider);
|
|
144
|
+
session.pluginHost.registerStatic(buildSynthesizeSkillPlugin(session));
|
|
145
|
+
// A pre-authored skill load_skill can resolve.
|
|
146
|
+
session.skills.register({
|
|
147
|
+
id: asSkillId('user/demo'),
|
|
148
|
+
path: '/demo.md',
|
|
149
|
+
scope: 'user',
|
|
150
|
+
frontmatter: { name: 'demo', description: 'd' },
|
|
151
|
+
body: 'steps',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const turnId = asTurnId('active-turn-2');
|
|
155
|
+
// Drive the tool through the registry exactly as the loop does: the
|
|
156
|
+
// ToolContext is built with the running turn's id, so the handler must
|
|
157
|
+
// stamp skill_invoked with THAT id (not a fresh startTurn() orphan that the
|
|
158
|
+
// run-turn subscriber filter would drop).
|
|
159
|
+
await session.tools.execute(
|
|
160
|
+
'load_skill',
|
|
161
|
+
{ name: 'demo' },
|
|
162
|
+
session.signal,
|
|
163
|
+
{ turnId: String(turnId), sessionId: String(session.id), callId: 'c1' },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const invoked = session.log.ofType('skill_invoked');
|
|
167
|
+
expect(invoked).toHaveLength(1);
|
|
168
|
+
expect(invoked[0].turnId).toBe(turnId);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
asSkillId,
|
|
5
|
+
definePlugin,
|
|
6
|
+
defineTool,
|
|
7
|
+
skillFrontmatterSchema,
|
|
8
|
+
type Plugin,
|
|
9
|
+
type Skill,
|
|
10
|
+
type SkillScope,
|
|
11
|
+
type TurnId,
|
|
12
|
+
} from '@moxxy/sdk';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { defaultProjectSkillsDir, defaultUserSkillsDir } from './loader.js';
|
|
15
|
+
import { draftSkill } from './synthesize-draft.js';
|
|
16
|
+
import type { Session } from '../session.js';
|
|
17
|
+
|
|
18
|
+
export interface SynthesizeOptions {
|
|
19
|
+
readonly userDir?: string;
|
|
20
|
+
readonly projectDir?: string;
|
|
21
|
+
readonly model?: string;
|
|
22
|
+
readonly auditPath?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Directory holding builtin skills (`@moxxy/skills-builtin`). Threaded
|
|
25
|
+
* through so `reload_skills` can rescan the same source set as the boot
|
|
26
|
+
* loader — otherwise reload silently drops the builtins, observed when
|
|
27
|
+
* a session called `reload_skills` and lost every shipped skill.
|
|
28
|
+
*/
|
|
29
|
+
readonly builtinDir?: string;
|
|
30
|
+
/** Extra plugin-supplied skill directories. Same boot-vs-reload story. */
|
|
31
|
+
readonly pluginDirs?: ReadonlyArray<string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SynthesizedSkill {
|
|
35
|
+
readonly skill: Skill;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly scope: SkillScope;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function synthesizeSkill(
|
|
41
|
+
session: Session,
|
|
42
|
+
intent: string,
|
|
43
|
+
scope: 'user' | 'project',
|
|
44
|
+
opts: SynthesizeOptions = {},
|
|
45
|
+
/**
|
|
46
|
+
* The active turn's id, threaded from the tool's `ctx.turnId`. The
|
|
47
|
+
* `skill_created` event MUST carry it so run-turn's per-turn subscriber
|
|
48
|
+
* (`event.turnId !== turnId → drop`) doesn't filter the event out of the
|
|
49
|
+
* running turn's stream. Falls back to a fresh turn only for legacy callers
|
|
50
|
+
* that invoke this outside a turn.
|
|
51
|
+
*/
|
|
52
|
+
turnId?: TurnId,
|
|
53
|
+
): Promise<SynthesizedSkill> {
|
|
54
|
+
const provider = session.providers.getActive();
|
|
55
|
+
// Prefer the model the conversation last ran on over the provider's first
|
|
56
|
+
// descriptor; 'default' matches run-turn's terminal fallback (never a
|
|
57
|
+
// hardcoded vendor id that goes stale).
|
|
58
|
+
const model =
|
|
59
|
+
opts.model ?? session.lastResolvedModel ?? provider.models[0]?.id ?? 'default';
|
|
60
|
+
const draft = await draftSkill(provider, model, intent, session.signal);
|
|
61
|
+
|
|
62
|
+
const baseDir =
|
|
63
|
+
scope === 'project'
|
|
64
|
+
? opts.projectDir ?? defaultProjectSkillsDir(session.cwd)
|
|
65
|
+
: opts.userDir ?? defaultUserSkillsDir();
|
|
66
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
67
|
+
|
|
68
|
+
// Validate the LLM-drafted frontmatter against the published schema BEFORE
|
|
69
|
+
// we write it to disk or register it. A model that returns sloppy YAML
|
|
70
|
+
// (missing description, illegal slug, etc.) should fail with a single
|
|
71
|
+
// readable line — the raw zod issue array was dumping ~30 lines of
|
|
72
|
+
// angry red JSON into the chat, which is a worse signal than just
|
|
73
|
+
// saying "the model didn't produce valid frontmatter."
|
|
74
|
+
const parsed = skillFrontmatterSchema.safeParse(draft.frontmatter);
|
|
75
|
+
if (!parsed.success) {
|
|
76
|
+
const missing = parsed.error.issues
|
|
77
|
+
.map((iss) => iss.path.join('.') || '(root)')
|
|
78
|
+
.join(', ');
|
|
79
|
+
throw new Error(
|
|
80
|
+
`synthesize_skill: the model didn't produce valid skill frontmatter ` +
|
|
81
|
+
`(missing or invalid: ${missing}). This usually means the model returned ` +
|
|
82
|
+
`prose or a code block without proper YAML frontmatter. Try a more specific intent.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const frontmatter = parsed.data as Skill['frontmatter'];
|
|
86
|
+
|
|
87
|
+
const finalPath = await uniqueFilename(baseDir, slugify(frontmatter.name));
|
|
88
|
+
await fs.writeFile(finalPath, draft.raw, 'utf8');
|
|
89
|
+
|
|
90
|
+
// Derive the skill id from the on-disk filename, not from the LLM-supplied
|
|
91
|
+
// frontmatter name — otherwise synthesizing the same name twice collides
|
|
92
|
+
// even when uniqueFilename has just bumped the filename to `<slug>-2.md`.
|
|
93
|
+
const basename = path.basename(finalPath, '.md');
|
|
94
|
+
const skill: Skill = {
|
|
95
|
+
id: asSkillId(`${scope}/${basename}`),
|
|
96
|
+
path: finalPath,
|
|
97
|
+
scope,
|
|
98
|
+
frontmatter,
|
|
99
|
+
body: draft.body.trimEnd(),
|
|
100
|
+
};
|
|
101
|
+
session.skills.register(skill);
|
|
102
|
+
|
|
103
|
+
await session.log.append({
|
|
104
|
+
type: 'skill_created',
|
|
105
|
+
sessionId: session.id,
|
|
106
|
+
turnId: turnId ?? session.startTurn().turnId,
|
|
107
|
+
source: 'system',
|
|
108
|
+
skillId: skill.id,
|
|
109
|
+
name: skill.frontmatter.name,
|
|
110
|
+
path: finalPath,
|
|
111
|
+
scope,
|
|
112
|
+
originatingPrompt: intent,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const auditPath = opts.auditPath ?? path.join(defaultUserSkillsDir(), '.meta', 'created.jsonl');
|
|
116
|
+
await appendAudit(auditPath, {
|
|
117
|
+
slug: path.basename(finalPath, '.md'),
|
|
118
|
+
ts: new Date().toISOString(),
|
|
119
|
+
sessionId: String(session.id),
|
|
120
|
+
originatingPrompt: intent,
|
|
121
|
+
scope,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return { skill, path: finalPath, scope };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function slugify(name: string): string {
|
|
128
|
+
return name
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
131
|
+
.replace(/^-+|-+$/g, '')
|
|
132
|
+
.slice(0, 60);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function uniqueFilename(dir: string, base: string): Promise<string> {
|
|
136
|
+
let candidate = path.join(dir, `${base}.md`);
|
|
137
|
+
let n = 2;
|
|
138
|
+
while (await exists(candidate)) {
|
|
139
|
+
candidate = path.join(dir, `${base}-${n}.md`);
|
|
140
|
+
n += 1;
|
|
141
|
+
}
|
|
142
|
+
return candidate;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function exists(p: string): Promise<boolean> {
|
|
146
|
+
try {
|
|
147
|
+
await fs.access(p);
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function appendAudit(filePath: string, entry: Record<string, unknown>): Promise<void> {
|
|
155
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
156
|
+
await fs.appendFile(filePath, JSON.stringify(entry) + '\n', 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildSynthesizeSkillPlugin(
|
|
160
|
+
session: Session,
|
|
161
|
+
opts: SynthesizeOptions = {},
|
|
162
|
+
): Plugin {
|
|
163
|
+
return definePlugin({
|
|
164
|
+
name: '@moxxy/synthesize-skill',
|
|
165
|
+
version: '0.0.0',
|
|
166
|
+
tools: [
|
|
167
|
+
defineTool({
|
|
168
|
+
name: 'synthesize_skill',
|
|
169
|
+
description:
|
|
170
|
+
'Draft and persist a new Markdown skill for the given user intent. ' +
|
|
171
|
+
'Uses the active provider to generate the skill body. Returns the path of the created skill. ' +
|
|
172
|
+
'ALWAYS pass scope="user" (the default) unless the user has EXPLICITLY asked to scope ' +
|
|
173
|
+
'the skill to this project — "user" writes to ~/.moxxy/skills/ and the skill is ' +
|
|
174
|
+
'available across every project; "project" writes to <cwd>/.moxxy/skills/ and only ' +
|
|
175
|
+
'applies in this directory. Most skills are general-purpose; pick "project" only when ' +
|
|
176
|
+
'the user said something like "for this repo only" or "project-specific".',
|
|
177
|
+
inputSchema: z.object({
|
|
178
|
+
intent: z.string().min(1).describe('What the user is trying to do. One sentence is enough.'),
|
|
179
|
+
scope: z
|
|
180
|
+
.enum(['user', 'project'])
|
|
181
|
+
.optional()
|
|
182
|
+
.default('user')
|
|
183
|
+
.describe(
|
|
184
|
+
'Where to write the skill. "user" → ~/.moxxy/skills/ (default, recommended). ' +
|
|
185
|
+
'"project" → <cwd>/.moxxy/skills/ — ONLY when the user explicitly asks for a project-scoped skill.',
|
|
186
|
+
),
|
|
187
|
+
}),
|
|
188
|
+
permission: { action: 'prompt' },
|
|
189
|
+
handler: async ({ intent, scope }, ctx) => {
|
|
190
|
+
const result = await synthesizeSkill(session, intent, scope, opts, ctx.turnId);
|
|
191
|
+
return {
|
|
192
|
+
path: result.path,
|
|
193
|
+
scope: result.scope,
|
|
194
|
+
name: result.skill.frontmatter.name,
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
defineTool({
|
|
199
|
+
name: 'load_skill',
|
|
200
|
+
description:
|
|
201
|
+
'Fetch the full body (instructions) of a pre-authored skill by name. ' +
|
|
202
|
+
'The system prompt lists each skill\'s name, description, and triggers; ' +
|
|
203
|
+
'call this tool to retrieve the actual workflow when the user\'s intent ' +
|
|
204
|
+
'matches one of those skills. Returns the markdown body verbatim plus the ' +
|
|
205
|
+
'frontmatter metadata (allowed-tools, scope, etc.).',
|
|
206
|
+
inputSchema: z.object({
|
|
207
|
+
name: z
|
|
208
|
+
.string()
|
|
209
|
+
.min(1)
|
|
210
|
+
.describe('The exact skill name from the "Available skills" list in the system prompt.'),
|
|
211
|
+
}),
|
|
212
|
+
handler: async ({ name }, ctx) => {
|
|
213
|
+
const skill = session.skills.byName(name);
|
|
214
|
+
if (!skill) {
|
|
215
|
+
const known = session.skills
|
|
216
|
+
.list()
|
|
217
|
+
.map((s) => s.frontmatter.name)
|
|
218
|
+
.join(', ');
|
|
219
|
+
throw new Error(
|
|
220
|
+
`load_skill: no skill named "${name}". ` +
|
|
221
|
+
`Known skills: ${known || '(none registered)'}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
// Emit a skill_invoked event so the audit log captures which
|
|
225
|
+
// skills were actually exercised in this turn — useful for
|
|
226
|
+
// routing analytics and for the self-improver agent later. Use the
|
|
227
|
+
// active turn's id (ctx.turnId) so run-turn's per-turn subscriber
|
|
228
|
+
// filter doesn't drop it (a fresh startTurn() id wouldn't match).
|
|
229
|
+
await session.log.append({
|
|
230
|
+
type: 'skill_invoked',
|
|
231
|
+
sessionId: session.id,
|
|
232
|
+
turnId: ctx.turnId,
|
|
233
|
+
source: 'model',
|
|
234
|
+
skillId: skill.id,
|
|
235
|
+
name: skill.frontmatter.name,
|
|
236
|
+
reason: 'load_skill_tool',
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
name: skill.frontmatter.name,
|
|
240
|
+
description: skill.frontmatter.description,
|
|
241
|
+
scope: skill.scope,
|
|
242
|
+
allowedTools: skill.frontmatter['allowed-tools'] ?? null,
|
|
243
|
+
body: skill.body,
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
defineTool({
|
|
248
|
+
name: 'reload_skills',
|
|
249
|
+
description:
|
|
250
|
+
'Rescan all skill sources (builtin + plugin + ~/.moxxy/skills + ./.moxxy/skills), ' +
|
|
251
|
+
'registering any new or changed skills.',
|
|
252
|
+
inputSchema: z.object({}),
|
|
253
|
+
// Safe, idempotent, local-only rescan — never prompt. Without this the
|
|
254
|
+
// tool inherits the channel resolver's default, which denies in
|
|
255
|
+
// headless runs (the skill-author flow couldn't activate a new skill).
|
|
256
|
+
permission: { action: 'allow' },
|
|
257
|
+
handler: async () => {
|
|
258
|
+
const { discoverSkills } = await import('./loader.js');
|
|
259
|
+
// Discover first, swap second: never empty the registry while
|
|
260
|
+
// the fs scan is in flight, because concurrent skill lookups
|
|
261
|
+
// would observe an empty registry mid-reload. Pass the SAME
|
|
262
|
+
// source set the boot loader used (builtin + pluginDirs +
|
|
263
|
+
// user + project); a previous version of this handler omitted
|
|
264
|
+
// builtinDir/pluginDirs and reload silently nuked the builtin
|
|
265
|
+
// skill set.
|
|
266
|
+
const discovered = await discoverSkills({
|
|
267
|
+
projectDir: opts.projectDir ?? defaultProjectSkillsDir(session.cwd),
|
|
268
|
+
userDir: opts.userDir ?? defaultUserSkillsDir(),
|
|
269
|
+
...(opts.builtinDir ? { builtinDir: opts.builtinDir } : {}),
|
|
270
|
+
...(opts.pluginDirs ? { pluginDirs: opts.pluginDirs } : {}),
|
|
271
|
+
});
|
|
272
|
+
session.skills.replaceAll(discovered);
|
|
273
|
+
return `loaded ${discovered.length} skill${discovered.length === 1 ? '' : 's'}`;
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
defineTool({
|
|
277
|
+
name: 'load_tool',
|
|
278
|
+
description:
|
|
279
|
+
'Load a tool whose full schema was indexed but not sent (see "Loadable tools" ' +
|
|
280
|
+
'in the system prompt). Call this with the tool name; the tool becomes callable ' +
|
|
281
|
+
'on the next turn. Only needed when lazy tool loading is enabled — core tools ' +
|
|
282
|
+
'(Read/Write/Edit/Bash/Grep/Glob) are always available.',
|
|
283
|
+
inputSchema: z.object({
|
|
284
|
+
name: z.string().min(1).describe('Exact tool name from the "Loadable tools" index.'),
|
|
285
|
+
}),
|
|
286
|
+
permission: { action: 'allow' },
|
|
287
|
+
handler: ({ name }) => {
|
|
288
|
+
// The call itself is recorded in the log; `applyLazyTools` reads that
|
|
289
|
+
// to include the tool's schema on subsequent requests. Here we just
|
|
290
|
+
// validate the name and echo the description so the model can proceed.
|
|
291
|
+
const tool = session.tools.get(name);
|
|
292
|
+
if (!tool) {
|
|
293
|
+
const known = session.tools
|
|
294
|
+
.list()
|
|
295
|
+
.map((t) => t.name)
|
|
296
|
+
.join(', ');
|
|
297
|
+
throw new Error(`load_tool: no tool named "${name}". Known tools: ${known}.`);
|
|
298
|
+
}
|
|
299
|
+
return { name: tool.name, description: tool.description, loaded: true };
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { asSessionId, asTurnId, type MoxxyEvent } from '@moxxy/sdk';
|
|
3
|
+
import type { SessionRuntime } from '../session-runtime.js';
|
|
4
|
+
import type { EventLog } from '../events/log.js';
|
|
5
|
+
import { streamChildEventToParent } from './events.js';
|
|
6
|
+
|
|
7
|
+
const childTool: MoxxyEvent = {
|
|
8
|
+
type: 'tool_call_requested',
|
|
9
|
+
seq: 0,
|
|
10
|
+
id: 'e0',
|
|
11
|
+
ts: 0,
|
|
12
|
+
sessionId: asSessionId('child'),
|
|
13
|
+
turnId: asTurnId('ct'),
|
|
14
|
+
source: 'model',
|
|
15
|
+
callId: 'call-1',
|
|
16
|
+
name: 'list_dir',
|
|
17
|
+
input: { path: '.' },
|
|
18
|
+
} as unknown as MoxxyEvent;
|
|
19
|
+
|
|
20
|
+
describe('streamChildEventToParent', () => {
|
|
21
|
+
afterEach(() => vi.restoreAllMocks());
|
|
22
|
+
|
|
23
|
+
// u47-5: a parent-log append failure while forwarding child progress must be
|
|
24
|
+
// non-fatal (the subagent run continues) AND observable (not silently lost).
|
|
25
|
+
it('does not throw and surfaces a diagnostic when the parent log append rejects', async () => {
|
|
26
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
27
|
+
const append = vi.fn().mockRejectedValue(new Error('disk full'));
|
|
28
|
+
const parent = {
|
|
29
|
+
id: asSessionId('parent'),
|
|
30
|
+
log: { append } as unknown as EventLog,
|
|
31
|
+
} as unknown as SessionRuntime;
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), childTool),
|
|
35
|
+
).resolves.toBeUndefined();
|
|
36
|
+
|
|
37
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(stderr).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(String(stderr.mock.calls[0]![0])).toContain('disk full');
|
|
40
|
+
expect(String(stderr.mock.calls[0]![0])).toContain('subagent_tool_call');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('forwards a mapped child event to the parent log on the happy path', async () => {
|
|
44
|
+
const append = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
const parent = {
|
|
46
|
+
id: asSessionId('parent'),
|
|
47
|
+
log: { append } as unknown as EventLog,
|
|
48
|
+
} as unknown as SessionRuntime;
|
|
49
|
+
|
|
50
|
+
await streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), childTool);
|
|
51
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
52
|
+
const arg = append.mock.calls[0]![0] as { type: string; subtype: string };
|
|
53
|
+
expect(arg.type).toBe('plugin_event');
|
|
54
|
+
expect(arg.subtype).toBe('subagent_tool_call');
|
|
55
|
+
});
|
|
56
|
+
});
|