diff --git a/src/agent/identity.ts b/src/agent/identity.ts index 3d99b26..ace8f4f 100644 --- a/src/agent/identity.ts +++ b/src/agent/identity.ts @@ -4,6 +4,8 @@ import * as YAML from "yaml"; import { ZodError } from "zod"; import { AgentYamlSchema } from "./schema"; import type { AgentYaml as AgentIdentity } from "./schema"; +import { DEFAULT_MODEL } from "../config/models"; +import type { AllowedModel } from "../config/models"; export type { AgentYaml as AgentIdentity } from "./schema"; @@ -44,7 +46,8 @@ export function createAgentYaml( workspacePath: string, name: string, role: string, - channelId: string + channelId: string, + model: AllowedModel = DEFAULT_MODEL ): void { const identity: AgentIdentity = { name, @@ -54,6 +57,7 @@ export function createAgentYaml( .join(" "), role, channel_id: channelId, + model, }; const yamlContent = YAML.stringify(identity); @@ -169,13 +173,14 @@ export function setupAgentWorkspace( workspacePath: string, name: string, role: string, - channelId: string + channelId: string, + model: AllowedModel = DEFAULT_MODEL ): void { // Ensure workspace directory exists fs.mkdirSync(workspacePath, { recursive: true }); // Create all required files - createAgentYaml(workspacePath, name, role, channelId); + createAgentYaml(workspacePath, name, role, channelId, model); createClaudeMd(workspacePath, name, role); createClaudeConfig(workspacePath); } diff --git a/src/agent/runner.ts b/src/agent/runner.ts index cb2df3f..f3f3c65 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -7,6 +7,7 @@ import { resolveClaude } from "../runtime/resolve-claude"; import { sanitizedEnv } from "../runtime/env"; import { Semaphore } from "../runtime/concurrency"; import type { RunResult } from "./types"; +import { DEFAULT_MODEL } from "../config/models"; // --------------------------------------------------------------------------- // Module-level semaphore — lazy-initialised on first runAgent() call so the @@ -206,11 +207,14 @@ export async function runAgent(options: RunAgentOptions): Promise { // ~/.disclaw/workspaces/ (outside the DisClaw repo), so walk-up will never // reach the DisClaw project CLAUDE.md. The workspace CLAUDE.md is picked up // automatically because cwd is set to workspacePath. + const model = identity.model ?? DEFAULT_MODEL; const args = [ "-p", prompt, "--output-format", "json", + "--model", + model, ]; const child = spawn(claudeCommand, args, { diff --git a/src/agent/schema.ts b/src/agent/schema.ts index b1169c6..707b474 100644 --- a/src/agent/schema.ts +++ b/src/agent/schema.ts @@ -1,10 +1,14 @@ import { z } from "zod"; +import { ALLOWED_MODELS, DEFAULT_MODEL } from "../config/models"; export const AgentYamlSchema = z.object({ name: z.string().min(1), display_name: z.string().optional(), role: z.string().optional(), channel_id: z.string().min(1), + model: z + .enum(ALLOWED_MODELS.map((m) => m.id) as [string, ...string[]]) + .default(DEFAULT_MODEL), }); export type AgentYaml = z.infer; diff --git a/src/commands/new-agent.ts b/src/commands/new-agent.ts index f1c9488..d0ce4b6 100644 --- a/src/commands/new-agent.ts +++ b/src/commands/new-agent.ts @@ -3,11 +3,17 @@ import { ChannelType, SlashCommandBuilder, Guild, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuInteraction, + ComponentType, } from "discord.js"; import * as path from "path"; import { DisclawDatabase } from "../db/database"; import { DisclawConfig } from "../config/loader"; import { setupAgentWorkspace } from "../agent/identity"; +import { ALLOWED_MODELS, DEFAULT_MODEL } from "../config/models"; +import type { AllowedModel } from "../config/models"; // Allowed characters for agent names: lowercase letters, numbers, hyphens const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/; @@ -82,7 +88,50 @@ export async function handleNewAgent( return; } - await interaction.deferReply(); + // Model selection via ephemeral select menu (before deferring the final reply) + const modelSelect = new StringSelectMenuBuilder() + .setCustomId("model-select") + .setPlaceholder("Modell auswählen …") + .addOptions( + ALLOWED_MODELS.map((m) => ({ + label: m.label, + value: m.id, + default: m.id === DEFAULT_MODEL, + })) + ); + + const row = new ActionRowBuilder().addComponents( + modelSelect + ); + + await interaction.reply({ + content: `Welches Modell soll **${name}** verwenden?`, + components: [row], + ephemeral: true, + }); + + let selectedModel: AllowedModel = DEFAULT_MODEL; + + try { + const selectInteraction = await interaction.channel!.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + filter: (i: StringSelectMenuInteraction) => + i.customId === "model-select" && i.user.id === interaction.user.id, + time: 60_000, + }); + + selectedModel = selectInteraction.values[0] as AllowedModel; + await selectInteraction.update({ + content: `Modell **${selectedModel}** ausgewählt. Erstelle Agenten …`, + components: [], + }); + } catch { + // Timeout or no selection — use default, update the reply + await interaction.editReply({ + content: `Keine Auswahl — verwende Standardmodell **${DEFAULT_MODEL}**. Erstelle Agenten …`, + components: [], + }); + } try { // 1. Create Discord channel @@ -95,17 +144,20 @@ export async function handleNewAgent( // 2. Create workspace with full Claude Code environment const workspacePath = path.resolve(config.workspaces_root, name); - setupAgentWorkspace(workspacePath, name, role, channel.id); + setupAgentWorkspace(workspacePath, name, role, channel.id, selectedModel); // 3. Save to database db.createWorkspace(channel.id, guild.id, name, workspacePath); // 4. Reply with confirmation - await interaction.editReply( - `Agent **${name}** erstellt in <#${channel.id}>.\n` + + await interaction.editReply({ + content: + `Agent **${name}** erstellt in <#${channel.id}>.\n` + `Rolle: ${role}\n` + - `Workspace: \`${workspacePath}\`` - ); + `Modell: ${selectedModel}\n` + + `Workspace: \`${workspacePath}\``, + components: [], + }); // 5. Send welcome message in the new channel await channel.send( @@ -115,6 +167,9 @@ export async function handleNewAgent( } catch (error) { const msg = error instanceof Error ? error.message : "Unbekannter Fehler"; - await interaction.editReply(`Fehler beim Erstellen des Agenten: ${msg}`); + await interaction.editReply({ + content: `Fehler beim Erstellen des Agenten: ${msg}`, + components: [], + }); } } diff --git a/src/config/models.ts b/src/config/models.ts new file mode 100644 index 0000000..ea5ee52 --- /dev/null +++ b/src/config/models.ts @@ -0,0 +1,8 @@ +export const ALLOWED_MODELS = [ + { id: "claude-opus-4-6", label: "Claude Opus 4.6 (leistungsstark, langsamer)" }, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Standard, empfohlen)" }, + { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (schnell, günstig)" }, +] as const; + +export type AllowedModel = typeof ALLOWED_MODELS[number]["id"]; +export const DEFAULT_MODEL: AllowedModel = "claude-sonnet-4-6"; diff --git a/tasks/DIS-153.md b/tasks/DIS-153.md index 22a9bd1..84da1a8 100644 --- a/tasks/DIS-153.md +++ b/tasks/DIS-153.md @@ -1,12 +1,12 @@ --- id: DIS-153 -status: ready +status: in-progress phase: 1.5 priority: p2 labels: [phase:1.5, type:feat, priority:p2] branch: refinement/model-selection -assignee: null -started: null +assignee: developer-agent +started: 2026-04-13 pr: null merged: null --- diff --git a/tests/unit/runner-model.test.ts b/tests/unit/runner-model.test.ts new file mode 100644 index 0000000..37c63b3 --- /dev/null +++ b/tests/unit/runner-model.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +/** + * Unit tests for DIS-153: model selection per agent. + * + * cross-spawn is mocked so no real process is launched. + * Each test inspects the args passed to spawn to verify --model is set correctly. + */ + +// --------------------------------------------------------------------------- +// Spy on the args cross-spawn receives +// --------------------------------------------------------------------------- +let capturedArgs: string[] = []; + +vi.mock("cross-spawn", () => { + function makeMockChild(args: string[]) { + capturedArgs = args; + + const stdoutListeners: Record void>> = {}; + + const stdoutStream = { + setEncoding: () => {}, + on: (event: string, cb: (...a: unknown[]) => void) => { + if (!stdoutListeners[event]) stdoutListeners[event] = []; + stdoutListeners[event].push(cb); + + if (event === "data") { + Promise.resolve().then(() => { + for (const handler of stdoutListeners["data"] ?? []) { + handler(JSON.stringify({ result: "ok" })); + } + }); + } + }, + }; + + const listeners: Record void>> = {}; + const emit = (event: string, ...a: unknown[]) => { + for (const cb of listeners[event] ?? []) cb(...a); + }; + + const child = { + stdout: stdoutStream, + stderr: { setEncoding: () => {}, on: () => {} }, + stdin: { end: () => {} }, + on: (event: string, cb: (...a: unknown[]) => void) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(cb); + + if (event === "close") { + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => emit("close", 0)); + } + }, + kill: () => {}, + }; + + return child; + } + + return { + default: (_cmd: string, args: string[]) => makeMockChild(args), + }; +}); + +import { runAgent, _resetSemaphore } from "../../src/agent/runner"; +import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude"; +import { AgentYamlSchema } from "../../src/agent/schema"; + +// --------------------------------------------------------------------------- +// Shared workspace fixture +// --------------------------------------------------------------------------- + +describe("runner model selection (DIS-153)", () => { + let workspaceDir: string; + const originalEnv = { ...process.env }; + + beforeAll(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-model-test-")); + fs.writeFileSync( + path.join(workspaceDir, "CLAUDE.md"), + "# Model Test Agent\nYou are a test agent.\n", + "utf-8" + ); + }); + + beforeEach(() => { + capturedArgs = []; + _resetResolveClaudeCache(); + _resetSemaphore(4); + process.env.CLAUDE_PATH = "/fake/claude"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + _resetResolveClaudeCache(); + }); + + afterAll(() => { + try { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }); + + // ------------------------------------------------------------------------- + // Test 1: --model is passed when model is set in agent.yaml + // ------------------------------------------------------------------------- + it("passes --model claude-sonnet-4-6 when model is set in agent.yaml", async () => { + fs.writeFileSync( + path.join(workspaceDir, "agent.yaml"), + `name: model-agent\ndisplay_name: Model Agent\nrole: Testing\nchannel_id: "111"\nmodel: claude-sonnet-4-6\n`, + "utf-8" + ); + + await runAgent({ + workspacePath: workspaceDir, + channelName: "test", + userMessage: "hello", + conversationHistory: [], + }); + + const modelIdx = capturedArgs.indexOf("--model"); + expect(modelIdx).toBeGreaterThan(-1); + expect(capturedArgs[modelIdx + 1]).toBe("claude-sonnet-4-6"); + }); + + // ------------------------------------------------------------------------- + // Test 2: Default model is used when model field is absent from agent.yaml + // ------------------------------------------------------------------------- + it("passes default model when model field is absent from agent.yaml", async () => { + fs.writeFileSync( + path.join(workspaceDir, "agent.yaml"), + `name: model-agent\ndisplay_name: Model Agent\nrole: Testing\nchannel_id: "222"\n`, + "utf-8" + ); + + await runAgent({ + workspacePath: workspaceDir, + channelName: "test", + userMessage: "hello", + conversationHistory: [], + }); + + const modelIdx = capturedArgs.indexOf("--model"); + expect(modelIdx).toBeGreaterThan(-1); + // DEFAULT_MODEL is claude-sonnet-4-6 + expect(capturedArgs[modelIdx + 1]).toBe("claude-sonnet-4-6"); + }); + + // ------------------------------------------------------------------------- + // Test 3: Invalid model in agent.yaml → Zod validation throws + // ------------------------------------------------------------------------- + it("throws when agent.yaml contains an invalid model value", () => { + const rawYaml = `name: bad-model-agent\ndisplay_name: Bad\nrole: Testing\nchannel_id: "333"\nmodel: gpt-4-turbo\n`; + const parsed = { name: "bad-model-agent", display_name: "Bad", role: "Testing", channel_id: "333", model: "gpt-4-turbo" }; + + expect(() => AgentYamlSchema.parse(parsed)).toThrow(); + }); +});