From 66000a1d4894193c22408203df629ea189060319 Mon Sep 17 00:00:00 2001 From: masteryyh Date: Thu, 20 Aug 2026 17:12:18 +0800 Subject: [PATCH] feat: add apply_patch tool for Responses API Signed-off-by: masteryyh --- AGENTS.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- packages/agenty-cli/src/App.tsx | 44 +- packages/agenty-cli/src/api/client.test.ts | 24 +- packages/agenty-cli/src/api/client.ts | 84 +-- packages/agenty-cli/src/api/types.ts | 55 +- packages/agenty-cli/src/cli/agent.ts | 32 +- packages/agenty-cli/src/cli/init.ts | 30 +- packages/agenty-cli/src/cli/model.ts | 14 +- packages/agenty-cli/src/cli/provider.ts | 28 +- packages/agenty-cli/src/cli/utils.ts | 12 +- packages/agenty-cli/src/commands/registry.ts | 4 +- .../src/components/AgentOverlay.test.ts | 4 +- .../src/components/AgentOverlay.tsx | 38 +- .../src/components/ModelOverlay.test.ts | 100 +++ .../src/components/ModelOverlay.tsx | 685 ++++++++++++++++++ .../src/components/ProviderOverlay.tsx | 12 +- .../src/components/StatusOverlay.tsx | 2 +- .../src/components/WizardOverlay.tsx | 44 +- .../src/components/toolDisplay.test.ts | 34 + .../agenty-cli/src/components/toolDisplay.ts | 62 ++ .../src/components/wizardSetup.test.ts | 24 +- .../agenty-cli/src/components/wizardSetup.ts | 84 +-- .../agenty-cli/src/consts/providerPresets.ts | 47 +- packages/agenty-cli/src/state/store.test.ts | 95 ++- packages/agenty-cli/src/state/store.ts | 18 +- packages/agenty-core/README-CN.md | 15 +- packages/agenty-core/README.md | 17 +- packages/agenty-core/TESTING-CN.md | 2 +- packages/agenty-core/TESTING.md | 2 +- .../pkg/agentloop/builtin/apply_patch.go | 307 ++++++++ .../pkg/agentloop/builtin/apply_patch_test.go | 190 +++++ .../pkg/agentloop/builtin/register.go | 1 + .../pkg/agentloop/builtin/register_test.go | 1 + packages/agenty-core/pkg/agentloop/engine.go | 32 +- .../agenty-core/pkg/agentloop/engine_test.go | 10 +- .../agenty-core/pkg/agentloop/metadata.go | 8 +- .../pkg/agentloop/testhelper_test.go | 20 +- packages/agenty-core/pkg/agentloop/types.go | 5 +- packages/agenty-core/pkg/application/agent.go | 38 +- .../agenty-core/pkg/application/agent_test.go | 14 +- .../agenty-core/pkg/application/initialize.go | 18 +- .../pkg/application/initialize_test.go | 14 +- .../agenty-core/pkg/application/provider.go | 82 ++- .../pkg/application/provider_test.go | 34 +- .../agenty-core/pkg/application/session.go | 34 +- .../pkg/application/session_test.go | 32 +- .../pkg/application/testhelper_test.go | 60 +- .../agenty-core/pkg/domain/agent/agent.go | 8 +- .../pkg/domain/agent/repository.go | 4 +- .../agenty-core/pkg/domain/catalog/model.go | 2 +- .../pkg/domain/catalog/provider.go | 18 +- .../pkg/domain/catalog/provider_test.go | 8 +- .../pkg/domain/catalog/repository.go | 4 +- .../pkg/domain/conversation/compaction.go | 6 +- .../pkg/domain/conversation/content.go | 57 ++ .../pkg/domain/conversation/content_test.go | 48 ++ .../pkg/domain/conversation/events.go | 2 +- .../pkg/domain/conversation/repository.go | 4 +- .../pkg/domain/conversation/session.go | 8 +- .../pkg/domain/conversation/session_test.go | 6 +- .../pkg/domain/conversation/summary.go | 12 +- .../agenty-core/pkg/domain/shared/code.go | 29 + .../shared/{slug_test.go => code_test.go} | 12 +- .../agenty-core/pkg/domain/shared/misc.go | 14 +- .../pkg/domain/shared/model_code.go | 37 + .../pkg/domain/shared/model_code_test.go | 42 ++ .../agenty-core/pkg/domain/shared/model_id.go | 31 - .../pkg/domain/shared/model_id_test.go | 44 -- .../agenty-core/pkg/domain/shared/slug.go | 29 - .../pkg/infra/initialize/initialize_test.go | 20 +- .../agenty-core/pkg/infra/llm/anthropic.go | 12 +- .../agenty-core/pkg/infra/llm/contract.go | 2 +- packages/agenty-core/pkg/infra/llm/convert.go | 4 +- .../agenty-core/pkg/infra/llm/convert_test.go | 270 ++++++- packages/agenty-core/pkg/infra/llm/factory.go | 16 +- packages/agenty-core/pkg/infra/llm/google.go | 22 +- .../pkg/infra/llm/live_integration_test.go | 20 +- .../agenty-core/pkg/infra/llm/openai_chat.go | 16 +- .../pkg/infra/llm/openai_responses.go | 362 ++++++++- .../pkg/infra/rpc/adapter/adapter_test.go | 68 +- .../pkg/infra/rpc/adapter/agent.go | 24 +- .../pkg/infra/rpc/adapter/provider.go | 30 +- .../pkg/infra/rpc/adapter/session.go | 10 +- .../agenty-core/pkg/infra/storage/agent.go | 16 +- .../pkg/infra/storage/agent_test.go | 20 +- .../agenty-core/pkg/infra/storage/catalog.go | 144 +--- .../pkg/infra/storage/catalog_test.go | 76 +- .../pkg/infra/storage/conversation.go | 36 +- .../pkg/infra/storage/conversation_test.go | 64 +- packages/agenty-core/pkg/infra/storage/db.go | 8 +- .../agenty-core/pkg/infra/storage/db_test.go | 4 +- packages/agenty-core/pkg/utils/apply_diff.go | 393 ++++++++++ .../agenty-core/pkg/utils/apply_diff_test.go | 258 +++++++ .../test/e2e/agenty_client_test.go | 34 +- .../agenty-core/test/e2e/contracts_test.go | 40 +- .../agenty-core/test/e2e/execution_test.go | 18 +- packages/agenty-core/test/e2e/journey_test.go | 68 +- .../test/e2e/live_provider_test.go | 26 +- .../agenty-core/test/e2e/protocol_test.go | 4 +- .../test/e2e/provider_fixture_test.go | 2 + .../agenty-core/test/e2e/test_helpers_test.go | 20 +- 103 files changed, 3882 insertions(+), 1168 deletions(-) create mode 100644 packages/agenty-cli/src/components/ModelOverlay.test.ts create mode 100644 packages/agenty-cli/src/components/ModelOverlay.tsx create mode 100644 packages/agenty-core/pkg/agentloop/builtin/apply_patch.go create mode 100644 packages/agenty-core/pkg/agentloop/builtin/apply_patch_test.go create mode 100644 packages/agenty-core/pkg/domain/shared/code.go rename packages/agenty-core/pkg/domain/shared/{slug_test.go => code_test.go} (81%) create mode 100644 packages/agenty-core/pkg/domain/shared/model_code.go create mode 100644 packages/agenty-core/pkg/domain/shared/model_code_test.go delete mode 100644 packages/agenty-core/pkg/domain/shared/model_id.go delete mode 100644 packages/agenty-core/pkg/domain/shared/model_id_test.go delete mode 100644 packages/agenty-core/pkg/domain/shared/slug.go create mode 100644 packages/agenty-core/pkg/utils/apply_diff.go create mode 100644 packages/agenty-core/pkg/utils/apply_diff_test.go diff --git a/AGENTS.md b/AGENTS.md index 35fc7d7..9887423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Core data is local-first: - Config: `~/.agenty/config.json` - Sessions: append-only JSONL under `~/.agenty/sessions/` - Session projection: `~/.agenty/agenty.sqlite` -- Providers/models: `~/.agenty/providers/` +- Providers/models: `~/.agenty/providers/.json` (models embedded) - Agents: `~/.agenty/agents/` - Logs: `~/.agenty/logs///
/core.log` diff --git a/README.md b/README.md index 8104112..2ee4c2d 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Core stores data under `~/.agenty` by default. Pass `--data-dir ` to the C | Configuration | `~/.agenty/config.json` | | Session transcripts | `~/.agenty/sessions///
/.jsonl` | | Session index | `~/.agenty/agenty.sqlite` | -| Providers and models | `~/.agenty/providers/` | +| Providers and models | `~/.agenty/providers/.json` (models embedded) | | Agents | `~/.agenty/agents/` | | Logs | `~/.agenty/logs///
/core.log` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 85d63ba..c5dcb7d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,7 +53,7 @@ core 默认把数据保存在 `~/.agenty`。可向 CLI 传入 `--data-dir | 配置 | `~/.agenty/config.json` | | 会话 transcript | `~/.agenty/sessions///
/.jsonl` | | 会话索引 | `~/.agenty/agenty.sqlite` | -| Providers 和 models | `~/.agenty/providers/` | +| Providers 和 models | `~/.agenty/providers/.json`(模型内嵌) | | Agents | `~/.agenty/agents/` | | 日志 | `~/.agenty/logs///
/core.log` | diff --git a/packages/agenty-cli/src/App.tsx b/packages/agenty-cli/src/App.tsx index 7cb9ed1..eaf062d 100644 --- a/packages/agenty-cli/src/App.tsx +++ b/packages/agenty-cli/src/App.tsx @@ -1,14 +1,15 @@ import { useRenderer, useSelectionHandler } from "@opentui/react"; import { useState } from "react"; -import type { ChatSessionDto, ModelDto } from "./api/types"; +import type { ChatSessionDto } from "./api/types"; import { commands, parseCommandTokens } from "./commands/registry"; import { AgentOverlay } from "./components/AgentOverlay"; -import { BottomDialog, useBottomDialogSize } from "./components/BottomDialog"; +import { BottomDialog } from "./components/BottomDialog"; import { CommandPalette } from "./components/CommandPalette"; import { InputBox } from "./components/InputBox"; import { LogoHeader } from "./components/LogoHeader"; import { MessageList } from "./components/MessageList"; +import { ModelOverlay } from "./components/ModelOverlay"; import { ProviderOverlay } from "./components/ProviderOverlay"; import { SelectOverlay } from "./components/SelectOverlay"; import { StatusOverlay } from "./components/StatusOverlay"; @@ -24,7 +25,7 @@ const INPUT_TOP_GAP = 1; const PROVIDER_OVERLAY_HEIGHT = 18; const AGENTS_OVERLAY_HEIGHT = 18; const STATUS_OVERLAY_HEIGHT = 14; -const MODEL_OVERLAY_HEIGHT = 18; +const MODEL_OVERLAY_HEIGHT = 20; function panelHeight(overlay: OverlayKind): number | null { switch (overlay) { @@ -275,7 +276,7 @@ function ChatView() { onTab={handleTab} streaming={busy} phrase={chat.phrase} - modelName={`${app.model?.providerName ?? "?"}/${app.model?.name ?? "?"}`} + modelName={`${app.model?.providerName ?? "?"} · ${app.model?.name ?? "?"}`} cwd={app.session?.cwd ?? process.cwd()} contextWindow={app.session?.contextWindow ?? 0} tokenConsumed={chat.tokenConsumed} @@ -303,12 +304,8 @@ function OverlayPanel({ }: { kind: "provider" | "agents" | "status" | "model-select"; }) { - const app = useApp(); return kind === "model-select" ? ( - app.setOverlay(null)} - onSelect={(model) => void app.switchModel(model)} - /> + ) : kind === "provider" ? ( ) : kind === "agents" ? ( @@ -318,35 +315,6 @@ function OverlayPanel({ ) : ; } -function ModelSelectOverlay({ - onClose, - onSelect, -}: { - onClose: () => void; - onSelect: (model: ModelDto) => void; -}) { - const client = useAppStore((s) => s.client); - const dialogSize = useBottomDialogSize(); - return ( - - title="Switch Model" - dialog - visibleOptionCount={Math.max(dialogSize.height - 2, 1)} - emptyHint="No switchable chat models found" - onClose={onClose} - onSelect={onSelect} - load={async () => { - const models = client ? await client.listModels() : []; - return models - .map((m) => ({ - label: `${m.providerName}/${m.name}`, - data: m, - })); - }} - /> - ); -} - function SessionSelectOverlay({ onClose, onSelect, diff --git a/packages/agenty-cli/src/api/client.test.ts b/packages/agenty-cli/src/api/client.test.ts index 2b454f1..17007f5 100644 --- a/packages/agenty-cli/src/api/client.test.ts +++ b/packages/agenty-cli/src/api/client.test.ts @@ -13,9 +13,9 @@ describe("AgentyClient session list", () => { await expect(client.isInitialized()).resolves.toBe(false); await expect(client.completeInitialization({ - agentSlug: "default", - providerSlug: "openai", - modelSlug: "gpt-test", + agentCode: "default", + providerCode: "openai", + modelCode: "gpt-test", })).resolves.toEqual({ initialized: false }); }); @@ -40,7 +40,7 @@ describe("AgentyClient session list", () => { test("normalizes null provider models before model projection", async () => { const provider = { - slug: "empty", + code: "empty", name: "Empty", type: "openai", baseUrl: "https://example.invalid", @@ -93,7 +93,7 @@ describe("AgentyClient session list", () => { test("normalizes null session collections for old core responses", async () => { const session = { id: "session", - agentSlug: "default", + agentCode: "default", contextWindow: 128000, rounds: null, createdAt: "2026-01-01T00:00:00Z", @@ -108,16 +108,16 @@ describe("AgentyClient session list", () => { }); test("updates a resumed session when an explicit model is requested", async () => { - const agent = { slug: "default", name: "Default" } as AgentDto; - const currentModel = { providerSlug: "openai", modelSlug: "gpt-old" }; + const agent = { code: "default", name: "Default" } as AgentDto; + const currentModel = { providerCode: "openai", modelCode: "gpt-old" }; const requestedModel = { - slug: "gpt-new", - providerSlug: "openai", + code: "gpt-new", + providerCode: "openai", providerName: "OpenAI", } as ModelDto; const existing = { id: "session", - agentSlug: "default", + agentCode: "default", currentModel, rounds: [], } as unknown as ChatSessionDto; @@ -128,7 +128,7 @@ describe("AgentyClient session list", () => { client.getLastSessionByAgent = async () => existing; client.setSessionModel = async (_id, model) => { updatedWith = model; - return { ...existing, currentModel: { providerSlug: model.providerSlug, modelSlug: model.slug } }; + return { ...existing, currentModel: { providerCode: model.providerCode, modelCode: model.code } }; }; const prepared = await client.prepareSession({ @@ -139,6 +139,6 @@ describe("AgentyClient session list", () => { expect(updatedWith).toBe(requestedModel); expect(prepared.model).toBe(requestedModel); - expect(prepared.session.currentModel).toEqual({ providerSlug: "openai", modelSlug: "gpt-new" }); + expect(prepared.session.currentModel).toEqual({ providerCode: "openai", modelCode: "gpt-new" }); }); }); diff --git a/packages/agenty-cli/src/api/client.ts b/packages/agenty-cli/src/api/client.ts index 63783c9..481e502 100644 --- a/packages/agenty-cli/src/api/client.ts +++ b/packages/agenty-cli/src/api/client.ts @@ -80,7 +80,7 @@ export class AgentyClient { return agents.find((agent) => agent.isDefault) ?? agents[0]; } const lower = reference.toLowerCase(); - const matched = agents.find((agent) => agent.slug === reference) ?? + const matched = agents.find((agent) => agent.code === reference) ?? agents.find((agent) => agent.name.toLowerCase() === lower); if (!matched) { throw new Error(`agent not found: ${reference}`); @@ -96,16 +96,16 @@ export class AgentyClient { return agent; } - async updateAgent(slug: string, input: UpdateAgentDto): Promise { - const agent = await this.rpc.call("agent.update", { slug, ...input }); + async updateAgent(code: string, input: UpdateAgentDto): Promise { + const agent = await this.rpc.call("agent.update", { code, ...input }); if (!agent) { - throw new Error(`core returned an empty agent for ${slug}`); + throw new Error(`core returned an empty agent for ${code}`); } return agent; } - async deleteAgent(slug: string): Promise { - await this.rpc.call("agent.delete", { slug }); + async deleteAgent(code: string): Promise { + await this.rpc.call("agent.delete", { code }); } async listProviders(): Promise { @@ -127,16 +127,16 @@ export class AgentyClient { return normalizeProvider(provider); } - async updateProvider(slug: string, input: UpdateModelProviderDto): Promise { - const provider = await this.rpc.call("provider.update", { slug, ...input }); + async updateProvider(code: string, input: UpdateModelProviderDto): Promise { + const provider = await this.rpc.call("provider.update", { code, ...input }); if (!provider) { - throw new Error(`core returned an empty provider for ${slug}`); + throw new Error(`core returned an empty provider for ${code}`); } return normalizeProvider(provider); } - async deleteProvider(slug: string): Promise { - await this.rpc.call("provider.delete", { slug }); + async deleteProvider(code: string): Promise { + await this.rpc.call("provider.delete", { code }); } async listModels(): Promise { @@ -164,9 +164,9 @@ export class AgentyClient { const models = await this.listModels(); const lower = reference.toLowerCase(); const matches = models.filter((model) => - model.slug === reference || + model.code === reference || model.name.toLowerCase() === lower || - `${model.providerSlug}/${model.slug}`.toLowerCase() === lower || + `${model.providerCode}/${model.code}`.toLowerCase() === lower || `${model.providerName}/${model.name}`.toLowerCase() === lower, ); if (matches.length !== 1) { @@ -177,27 +177,27 @@ export class AgentyClient { async createModel(input: CreateModelDto): Promise { const provider = await this.rpc.call("provider.addModel", input); - return findProjectedModel(provider, input.modelSlug); + return findProjectedModel(provider, input.modelCode); } - async updateModel(providerSlug: string, modelSlug: string, input: UpdateModelDto): Promise { + async updateModel(providerCode: string, modelCode: string, input: UpdateModelDto): Promise { const provider = await this.rpc.call("provider.addModel", { - providerSlug, - modelSlug, + providerCode, + modelCode, ...input, }); - return findProjectedModel(provider, modelSlug); + return findProjectedModel(provider, modelCode); } - async deleteModel(providerSlug: string, modelSlug: string): Promise { - await this.rpc.call("provider.removeModel", { providerSlug, modelSlug }); + async deleteModel(providerCode: string, modelCode: string): Promise { + await this.rpc.call("provider.removeModel", { providerCode, modelCode }); } - async createSession(agentSlug: string, model: ModelDto, effort: ReasoningEffort = "off"): Promise { + async createSession(agentCode: string, model: ModelDto, effort: ReasoningEffort = "off"): Promise { const session = await this.rpc.call("session.create", { - agentSlug, - providerSlug: model.providerSlug, - modelSlug: model.slug, + agentCode, + providerCode: model.providerCode, + modelCode: model.code, contextWindow: model.contextWindow, reasoningEffort: effort, }); @@ -209,21 +209,21 @@ export class AgentyClient { return requireSession(session, `session.get ${id}`); } - async listSessionSummaries(agentSlug?: string): Promise { + async listSessionSummaries(agentCode?: string): Promise { const summaries = await this.rpc.call | null>( "session.list", - agentSlug ? { agentSlug } : {}, + agentCode ? { agentCode } : {}, ); return (summaries ?? []).filter((summary): summary is SessionSummaryDto => summary !== null); } - async listSessions(agentSlug?: string): Promise { - const summaries = await this.listSessionSummaries(agentSlug); + async listSessions(agentCode?: string): Promise { + const summaries = await this.listSessionSummaries(agentCode); return Promise.all(summaries.map((session) => this.getSession(session.id))); } - async getLastSessionByAgent(agentSlug: string): Promise { - const sessions = await this.listSessionSummaries(agentSlug); + async getLastSessionByAgent(agentCode: string): Promise { + const sessions = await this.listSessionSummaries(agentCode); return sessions.length > 0 ? this.getSession(sessions[0].id) : null; } @@ -235,8 +235,8 @@ export class AgentyClient { async setSessionModel(id: string, model: ModelDto): Promise { const session = await this.rpc.call("session.setModel", { id, - providerSlug: model.providerSlug, - modelSlug: model.slug, + providerCode: model.providerCode, + modelCode: model.code, }); return requireSession(session, `session.setModel ${id}`); } @@ -271,11 +271,11 @@ export class AgentyClient { }): Promise { const agent = await this.resolveAgent(options.agentRef); const requestedModel = options.modelRef ? await this.resolveModel(options.modelRef) : undefined; - let session = options.newSession ? null : await this.getLastSessionByAgent(agent.slug); + let session = options.newSession ? null : await this.getLastSessionByAgent(agent.code); if (!session) { const model = requestedModel ?? await this.resolveAgentModel(agent); session = await this.createSession( - agent.slug, + agent.code, model, options.reasoningEffort ?? agent.defaultReasoningEffort ?? "off", ); @@ -283,8 +283,8 @@ export class AgentyClient { } if (requestedModel) { - const matchesCurrent = session.currentModel?.providerSlug === requestedModel.providerSlug && - session.currentModel.modelSlug === requestedModel.slug; + const matchesCurrent = session.currentModel?.providerCode === requestedModel.providerCode && + session.currentModel.modelCode === requestedModel.code; if (!matchesCurrent) { session = await this.setSessionModel(session.id, requestedModel); } @@ -293,7 +293,7 @@ export class AgentyClient { if (session.currentModel) { const model = await this.resolveModel( - `${session.currentModel.providerSlug}/${session.currentModel.modelSlug}`, + `${session.currentModel.providerCode}/${session.currentModel.modelCode}`, ); return { agent, model, session }; } @@ -305,7 +305,7 @@ export class AgentyClient { private async resolveAgentModel(agent: AgentDto): Promise { if (agent.defaultModel) { - return this.resolveModel(`${agent.defaultModel.providerSlug}/${agent.defaultModel.modelSlug}`); + return this.resolveModel(`${agent.defaultModel.providerCode}/${agent.defaultModel.modelCode}`); } return this.getDefaultModel(); } @@ -314,7 +314,7 @@ export class AgentyClient { function projectModel(provider: ModelProviderDto, model: CoreModelDto): ModelDto { return { ...model, - providerSlug: provider.slug, + providerCode: provider.code, providerName: provider.name, }; } @@ -386,14 +386,14 @@ function requireSession(session: ChatSessionDto | null, operation: string): Chat return normalizeSession(session); } -function findProjectedModel(provider: ModelProviderDto | null, modelSlug: string): ModelDto { +function findProjectedModel(provider: ModelProviderDto | null, modelCode: string): ModelDto { if (!provider) { throw new Error("core returned an empty provider while adding a model"); } const normalizedProvider = normalizeProvider(provider); - const model = normalizedProvider.models.find((candidate) => candidate.slug === modelSlug); + const model = normalizedProvider.models.find((candidate) => candidate.code === modelCode); if (!model) { - throw new Error(`core did not return model ${normalizedProvider.slug}/${modelSlug}`); + throw new Error(`core did not return model ${normalizedProvider.code}/${modelCode}`); } return projectModel(normalizedProvider, model); } diff --git a/packages/agenty-cli/src/api/types.ts b/packages/agenty-cli/src/api/types.ts index 365a1ad..7cd0d60 100644 --- a/packages/agenty-cli/src/api/types.ts +++ b/packages/agenty-cli/src/api/types.ts @@ -2,12 +2,12 @@ export type ReasoningEffort = "" | "off" | "low" | "medium" | "high" | "xhigh" | export type APIType = "openai" | "openai_completions" | "anthropic" | "gemini"; export interface ModelRef { - providerSlug: string; - modelSlug: string; + providerCode: string; + modelCode: string; } export interface AgentDto { - slug: string; + code: string; name: string; description?: string; soul: string; @@ -21,7 +21,7 @@ export interface AgentDto { } export interface CreateAgentDto { - slug: string; + code: string; name: string; description?: string; soul?: string; @@ -32,11 +32,11 @@ export interface CreateAgentDto { metadata?: Record; } -export type UpdateAgentDto = Partial>; +export type UpdateAgentDto = Partial>; export interface ModelDto { - slug: string; - providerSlug: string; + code: string; + providerCode: string; providerName: string; name: string; contextWindow: number; @@ -50,11 +50,11 @@ export interface ModelDto { updatedAt?: string; } -export interface CoreModelDto extends Omit {} +export interface CoreModelDto extends Omit {} export interface CreateModelDto { - providerSlug: string; - modelSlug: string; + providerCode: string; + modelCode: string; name: string; contextWindow?: number; /** @deprecated Core ignores per-model output limits and uses 8192. */ @@ -65,10 +65,10 @@ export interface CreateModelDto { isDefault?: boolean; } -export type UpdateModelDto = Omit; +export type UpdateModelDto = Omit; export interface ModelProviderDto { - slug: string; + code: string; name: string; type: APIType; baseUrl: string; @@ -80,7 +80,7 @@ export interface ModelProviderDto { } export interface CreateModelProviderDto { - slug: string; + code: string; name: string; type: APIType; baseUrl?: string; @@ -88,7 +88,7 @@ export interface CreateModelProviderDto { metadata?: Record; } -export type UpdateModelProviderDto = Partial>; +export type UpdateModelProviderDto = Partial>; export type ContentBlock = | { type: "text"; text: string } @@ -113,6 +113,19 @@ export type ContentBlock = outcome: { type: string; exitCode?: number }; }>; } + | { + type: "apply_patch_call"; + id?: string; + callId: string; + source: "native" | "custom"; + operation?: { + type: "create_file" | "update_file" | "delete_file"; + path: string; + diff?: string; + moveTo?: string; + }; + patch?: string; + } | { type: "tool_result"; toolUseId: string; content: ContentBlock[]; isError: boolean } | { type: "image"; mediaType: string; data: string }; @@ -154,7 +167,7 @@ export interface RoundDto { export interface ChatSessionDto { id: string; - agentSlug: string; + agentCode: string; title?: string; cwd?: string; currentModel?: ModelRef; @@ -168,9 +181,9 @@ export interface ChatSessionDto { export interface SessionSummaryDto { id: string; title: string; - agentSlug: string; - lastProviderSlug: string; - lastModelSlug: string; + agentCode: string; + lastProviderCode: string; + lastModelCode: string; contextWindow: number; lastReasoningEffort?: ReasoningEffort; createdAt: string; @@ -217,9 +230,9 @@ export interface ExecutionStart { } export interface InitializeCompleteInput { - agentSlug: string; - providerSlug: string; - modelSlug: string; + agentCode: string; + providerCode: string; + modelCode: string; } export interface PagedResponse { diff --git a/packages/agenty-cli/src/cli/agent.ts b/packages/agenty-cli/src/cli/agent.ts index b438335..f440719 100644 --- a/packages/agenty-cli/src/cli/agent.ts +++ b/packages/agenty-cli/src/cli/agent.ts @@ -25,34 +25,34 @@ export async function handleAgent(client: AgentyClient, args: ParsedArgs): Promi const result = await client.listAgentsPage(page, pageSize); render(args, result, () => result.data.length === 0 ? process.stdout.write("No agents.\n") - : outputTable(["Slug", "Name", "Default", "Model"], result.data.map((agent) => [ - agent.slug, agent.name, String(agent.isDefault), agent.defaultModel ? `${agent.defaultModel.providerSlug}/${agent.defaultModel.modelSlug}` : "", + : outputTable(["Agent Code", "Name", "Default", "Model"], result.data.map((agent) => [ + agent.code, agent.name, String(agent.isDefault), agent.defaultModel ? `${agent.defaultModel.providerCode}/${agent.defaultModel.modelCode}` : "", ]))); return; } if (command === "get") { - const [, , reference] = requirePositionals(args, 3, "agent get "); + const [, , reference] = requirePositionals(args, 3, "agent get "); const agent = await client.resolveAgent(reference); render(args, agent, () => outputFields([ - ["Slug", agent.slug], ["Name", agent.name], ["Soul", agent.soul], ["Default", String(agent.isDefault)], - ["Model", agent.defaultModel ? `${agent.defaultModel.providerSlug}/${agent.defaultModel.modelSlug}` : ""], + ["Agent Code", agent.code], ["Name", agent.name], ["Soul", agent.soul], ["Default", String(agent.isDefault)], + ["Model", agent.defaultModel ? `${agent.defaultModel.providerCode}/${agent.defaultModel.modelCode}` : ""], ])); return; } if (command === "add") { - const [, , slug] = requirePositionals(args, 3, "agent add [options]"); + const [, , code] = requirePositionals(args, 3, "agent add [options]"); const model = flag(args, "model") ? await resolveModel(client, flag(args, "model")!) : undefined; const created = await client.createAgent({ - slug, name: flag(args, "name")?.trim() || slug, soul: flag(args, "soul") ?? "", + code, name: flag(args, "name")?.trim() || code, soul: flag(args, "soul") ?? "", isDefault: hasFlag(args, "default") ? parseBoolean(flag(args, "default"), "--default") : false, - defaultModel: model ? { providerSlug: model.providerSlug, modelSlug: model.slug } : undefined, + defaultModel: model ? { providerCode: model.providerCode, modelCode: model.code } : undefined, defaultContextWindow: model?.contextWindow ?? 0, }); - action(args, created, `Agent added: ${created.slug}`); + action(args, created, `Agent added: ${created.code}`); return; } if (command === "update") { - const [, , reference] = requirePositionals(args, 3, "agent update [options]"); + const [, , reference] = requirePositionals(args, 3, "agent update [options]"); const current = await client.resolveAgent(reference); const update: UpdateAgentDto = {}; if (hasFlag(args, "name")) { @@ -66,24 +66,24 @@ export async function handleAgent(client: AgentyClient, args: ParsedArgs): Promi } if (hasFlag(args, "model")) { const model = await resolveModel(client, flag(args, "model")!); - update.defaultModel = { providerSlug: model.providerSlug, modelSlug: model.slug }; + update.defaultModel = { providerCode: model.providerCode, modelCode: model.code }; update.defaultContextWindow = model.contextWindow; } if (Object.keys(update).length === 0) { throw new CliError("no changes specified"); } - const updated = await client.updateAgent(current.slug, update); - action(args, updated, `Agent updated: ${updated.slug}`); + const updated = await client.updateAgent(current.code, update); + action(args, updated, `Agent updated: ${updated.code}`); return; } if (command === "remove") { - const [, , reference] = requirePositionals(args, 3, "agent remove --yes"); + const [, , reference] = requirePositionals(args, 3, "agent remove --yes"); if (!hasFlag(args, "yes")) { throw new CliError("use --yes to remove an agent non-interactively"); } const current = await client.resolveAgent(reference); - await client.deleteAgent(current.slug); - action(args, { slug: current.slug, deleted: true }, `Agent removed: ${current.slug}`); + await client.deleteAgent(current.code); + action(args, { code: current.code, deleted: true }, `Agent removed: ${current.code}`); return; } throw new CliError("usage: agent "); diff --git a/packages/agenty-cli/src/cli/init.ts b/packages/agenty-cli/src/cli/init.ts index d3f3cbc..b00f424 100644 --- a/packages/agenty-cli/src/cli/init.ts +++ b/packages/agenty-cli/src/cli/init.ts @@ -14,40 +14,40 @@ import { export async function handleInit(client: AgentyClient, args: ParsedArgs): Promise { requirePositionals(args, 1, "init [options]"); - const providerSlug = requireFlag(args, "provider"); - const modelSlug = requireFlag(args, "model"); - const agentSlug = flag(args, "agent")?.trim() || "default"; + const providerCode = requireFlag(args, "provider"); + const modelCode = requireFlag(args, "model"); + const agentCode = flag(args, "agent")?.trim() || "default"; const contextWindow = positiveInteger(flag(args, "context-window") ?? "128000", "--context-window"); const apiKey = secret(args, "api-key", "api-key-env", "provider API key") ?? ""; await client.createProvider({ - slug: providerSlug, - name: flag(args, "provider-name")?.trim() || providerSlug, + code: providerCode, + name: flag(args, "provider-name")?.trim() || providerCode, type: requireFlag(args, "type") as APIType, baseUrl: flag(args, "base-url")?.trim() || "", apiKey, }); await client.createModel({ - providerSlug, - modelSlug, - name: flag(args, "model-name")?.trim() || modelSlug, + providerCode, + modelCode, + name: flag(args, "model-name")?.trim() || modelCode, contextWindow, isDefault: true, }); await client.createAgent({ - slug: agentSlug, - name: flag(args, "agent-name")?.trim() || agentSlug, + code: agentCode, + name: flag(args, "agent-name")?.trim() || agentCode, soul: flag(args, "soul") ?? "", - defaultModel: { providerSlug, modelSlug }, + defaultModel: { providerCode, modelCode }, defaultContextWindow: contextWindow, isDefault: true, }); - const result = await client.completeInitialization({ agentSlug, providerSlug, modelSlug }); + const result = await client.completeInitialization({ agentCode, providerCode, modelCode }); render(args, result, () => outputFields([ ["Initialized", String(result.initialized)], - ["Provider", providerSlug], - ["Model", `${providerSlug}/${modelSlug}`], - ["Agent", agentSlug], + ["Provider", providerCode], + ["Model", `${providerCode}/${modelCode}`], + ["Agent", agentCode], ])); } diff --git a/packages/agenty-cli/src/cli/model.ts b/packages/agenty-cli/src/cli/model.ts index 3c4b529..7c3114b 100644 --- a/packages/agenty-cli/src/cli/model.ts +++ b/packages/agenty-cli/src/cli/model.ts @@ -46,12 +46,12 @@ export async function handleModel(client: AgentyClient, args: ParsedArgs): Promi return; } if (command === "add") { - const [, , modelSlug] = requirePositionals(args, 3, "model add --provider [options]"); + const [, , modelCode] = requirePositionals(args, 3, "model add --provider [options]"); const provider = await resolveProvider(client, requireFlag(args, "provider")); const created = await client.createModel({ - providerSlug: provider.slug, - modelSlug, - name: flag(args, "name")?.trim() || modelSlug, + providerCode: provider.code, + modelCode, + name: flag(args, "name")?.trim() || modelCode, contextWindow: positiveInteger(flag(args, "context-window") ?? "0", "--context-window", true), multiModal: booleanFlag(args, "multi-modal"), light: booleanFlag(args, "light"), @@ -72,7 +72,7 @@ export async function handleModel(client: AgentyClient, args: ParsedArgs): Promi isDefault: hasFlag(args, "default") ? booleanFlag(args, "default") : current.isDefault, reasoningEffortMapping: hasFlag(args, "reasoning-map") ? reasoningMapping(args) : current.reasoningEffortMapping, }; - const updated = await client.updateModel(current.providerSlug, current.slug, update); + const updated = await client.updateModel(current.providerCode, current.code, update); action(args, updated, `Model updated: ${displayModel(updated)}`); return; } @@ -82,8 +82,8 @@ export async function handleModel(client: AgentyClient, args: ParsedArgs): Promi throw new CliError("use --yes to remove a model non-interactively"); } const current = await resolveModel(client, reference); - await client.deleteModel(current.providerSlug, current.slug); - action(args, { providerSlug: current.providerSlug, modelSlug: current.slug, deleted: true }, `Model removed: ${displayModel(current)}`); + await client.deleteModel(current.providerCode, current.code); + action(args, { providerCode: current.providerCode, modelCode: current.code, deleted: true }, `Model removed: ${displayModel(current)}`); return; } throw new CliError("usage: model "); diff --git a/packages/agenty-cli/src/cli/provider.ts b/packages/agenty-cli/src/cli/provider.ts index 30d22e5..961eadb 100644 --- a/packages/agenty-cli/src/cli/provider.ts +++ b/packages/agenty-cli/src/cli/provider.ts @@ -25,35 +25,35 @@ export async function handleProvider(client: AgentyClient, args: ParsedArgs): Pr const result = await client.listProvidersPage(page, pageSize); render(args, result, () => result.data.length === 0 ? process.stdout.write("No providers.\n") - : outputTable(["Slug", "Name", "Type", "Base URL", "Models"], result.data.map((provider) => [ - provider.slug, provider.name, provider.type, provider.baseUrl, String(provider.models.length), + : outputTable(["Provider Code", "Name", "Type", "Base URL", "Models"], result.data.map((provider) => [ + provider.code, provider.name, provider.type, provider.baseUrl, String(provider.models.length), ]))); return; } if (command === "get") { - const [, , reference] = requirePositionals(args, 3, "provider get "); + const [, , reference] = requirePositionals(args, 3, "provider get "); const provider = await resolveProvider(client, reference); render(args, provider, () => outputFields([ - ["Slug", provider.slug], ["Name", provider.name], ["Type", provider.type], + ["Provider Code", provider.code], ["Name", provider.name], ["Type", provider.type], ["Base URL", provider.baseUrl], ["API Key", provider.apiKey ? "" : ""], ["Models", String(provider.models.length)], ])); return; } if (command === "add") { - const [, , slug] = requirePositionals(args, 3, "provider add --type [options]"); + const [, , code] = requirePositionals(args, 3, "provider add --type [options]"); const created = await client.createProvider({ - slug, - name: flag(args, "name")?.trim() || slug, + code, + name: flag(args, "name")?.trim() || code, type: requireFlag(args, "type") as APIType, baseUrl: flag(args, "base-url")?.trim() || "", apiKey: secret(args, "api-key", "api-key-env", "provider API key") ?? "", }); - action(args, created, `Provider added: ${created.slug}`); + action(args, created, `Provider added: ${created.code}`); return; } if (command === "update") { - const [, , reference] = requirePositionals(args, 3, "provider update [options]"); + const [, , reference] = requirePositionals(args, 3, "provider update [options]"); const current = await resolveProvider(client, reference); const update: UpdateModelProviderDto = {}; if (hasFlag(args, "name")) { @@ -72,18 +72,18 @@ export async function handleProvider(client: AgentyClient, args: ParsedArgs): Pr if (Object.keys(update).length === 0) { throw new CliError("no changes specified"); } - const updated = await client.updateProvider(current.slug, update); - action(args, updated, `Provider updated: ${updated.slug}`); + const updated = await client.updateProvider(current.code, update); + action(args, updated, `Provider updated: ${updated.code}`); return; } if (command === "remove") { - const [, , reference] = requirePositionals(args, 3, "provider remove --yes"); + const [, , reference] = requirePositionals(args, 3, "provider remove --yes"); if (!hasFlag(args, "yes")) { throw new CliError("use --yes to remove a provider non-interactively"); } const current = await resolveProvider(client, reference); - await client.deleteProvider(current.slug); - action(args, { slug: current.slug, deleted: true }, `Provider removed: ${current.slug}`); + await client.deleteProvider(current.code); + action(args, { code: current.code, deleted: true }, `Provider removed: ${current.code}`); return; } throw new CliError("usage: provider "); diff --git a/packages/agenty-cli/src/cli/utils.ts b/packages/agenty-cli/src/cli/utils.ts index e61c73c..f17eeb6 100644 --- a/packages/agenty-cli/src/cli/utils.ts +++ b/packages/agenty-cli/src/cli/utils.ts @@ -114,18 +114,18 @@ export async function resolveProvider(client: AgentyClient, reference: string): const lower = reference.toLowerCase(); const matched = providers.filter((provider) => - provider.slug === reference || provider.name.toLowerCase() === lower); + provider.code === reference || provider.name.toLowerCase() === lower); if (matched.length === 0) { throw new CliError(`provider not found: ${reference}`); } if (matched.length > 1) { - throw new CliError(`provider name is ambiguous: ${reference}; use provider ID instead`); + throw new CliError(`provider name is ambiguous: ${reference}; use provider code instead`); } return matched[0]; } export function displayModel(model: ModelDto): string { - return `${model.providerSlug}/${model.slug}`; + return `${model.providerCode}/${model.code}`; } export async function resolveModel(client: AgentyClient, reference: string): Promise { @@ -133,7 +133,7 @@ export async function resolveModel(client: AgentyClient, reference: string): Pro const lower = reference.toLowerCase(); const matched = models.filter((model) => - model.slug === reference || + model.code === reference || model.name.toLowerCase() === lower || displayModel(model).toLowerCase() === lower, ); @@ -141,13 +141,13 @@ export async function resolveModel(client: AgentyClient, reference: string): Pro throw new CliError(`model not found: ${reference}`); } if (matched.length > 1) { - throw new CliError(`model reference is ambiguous: ${reference}; use provider/name or model ID instead`); + throw new CliError(`model reference is ambiguous: ${reference}; use provider/name or model code instead`); } return matched[0]; } export function configured(model: ModelDto): boolean { - return model.providerSlug !== ""; + return model.providerCode !== ""; } export function hasFlag(args: ParsedArgs, name: string): boolean { diff --git a/packages/agenty-cli/src/commands/registry.ts b/packages/agenty-cli/src/commands/registry.ts index f93df48..b4957c5 100644 --- a/packages/agenty-cli/src/commands/registry.ts +++ b/packages/agenty-cli/src/commands/registry.ts @@ -16,13 +16,13 @@ export const commands: Command[] = [ }, { name: "/model", - description: "Switch the chat model", + description: "Manage and switch chat models", usage: "/model [provider/model]", argHint: "provider/model", completeArgs: async (client) => { const models = await client.listModels(); return models - .map((m) => `${m.providerSlug}/${m.slug}`); + .map((m) => `${m.providerCode}/${m.code}`); }, }, { diff --git a/packages/agenty-cli/src/components/AgentOverlay.test.ts b/packages/agenty-cli/src/components/AgentOverlay.test.ts index 62ccc1c..1700a54 100644 --- a/packages/agenty-cli/src/components/AgentOverlay.test.ts +++ b/packages/agenty-cli/src/components/AgentOverlay.test.ts @@ -3,9 +3,9 @@ import { describe, expect, test } from "bun:test"; import { parseModelRef } from "./AgentOverlay"; describe("parseModelRef", () => { - test("keeps slashes inside a model ID", () => { + test("keeps slashes inside a model code", () => { expect(parseModelRef("openai/org/model_name[v2]")) - .toEqual({ providerSlug: "openai", modelSlug: "org/model_name[v2]" }); + .toEqual({ providerCode: "openai", modelCode: "org/model_name[v2]" }); }); test("rejects references without both sides", () => { diff --git a/packages/agenty-cli/src/components/AgentOverlay.tsx b/packages/agenty-cli/src/components/AgentOverlay.tsx index e76d73f..a633edc 100644 --- a/packages/agenty-cli/src/components/AgentOverlay.tsx +++ b/packages/agenty-cli/src/components/AgentOverlay.tsx @@ -31,9 +31,9 @@ export function parseModelRef(raw: string): ModelRef | undefined { if (separator <= 0 || separator === raw.length - 1) { return undefined; } - const providerSlug = raw.slice(0, separator); - const modelSlug = raw.slice(separator + 1); - return { providerSlug, modelSlug }; + const providerCode = raw.slice(0, separator); + const modelCode = raw.slice(separator + 1); + return { providerCode, modelCode }; } type Mode = @@ -80,8 +80,8 @@ export function AgentOverlay() { setModels(models); setModelOptions( models.map((m) => ({ - label: `${m.providerName}/${m.name}`, - value: `${m.providerSlug}/${m.slug}`, + label: `${m.providerName} · ${m.name}`, + value: `${m.providerCode}/${m.code}`, })), ); } catch { @@ -116,10 +116,10 @@ export function AgentOverlay() { const buildFields = (target?: AgentDto): FormField[] => { const modelRef = target?.defaultModel - ? `${target.defaultModel.providerSlug}/${target.defaultModel.modelSlug}` + ? `${target.defaultModel.providerCode}/${target.defaultModel.modelCode}` : modelOptions[0]?.value ?? ""; return [ - { key: "slug", label: "Slug", kind: "text" as const, value: target?.slug ?? "", placeholder: "my-agent", readOnly: !!target }, + { key: "code", label: "Agent Code", kind: "text" as const, value: target?.code ?? "", placeholder: "my-agent", readOnly: !!target }, { key: "name", label: "Name", kind: "text" as const, value: target?.name ?? "", placeholder: "my-agent" }, { key: "soul", label: "Soul", kind: "text" as const, value: target?.soul ?? "", placeholder: "system prompt, leave blank for default" }, { key: "isDefault", label: "Default", kind: "boolean" as const, value: target ? (target.isDefault ? "true" : "false") : "false" }, @@ -133,9 +133,9 @@ export function AgentOverlay() { } try { const defaultModel = parseModelRef(values.defaultModel); - const selectedModel = models.find((model) => `${model.providerSlug}/${model.slug}` === values.defaultModel); + const selectedModel = models.find((model) => `${model.providerCode}/${model.code}` === values.defaultModel); await client.createAgent({ - slug: values.slug.trim(), + code: values.code.trim(), name: values.name.trim(), soul: values.soul.trim(), isDefault: values.isDefault === "true", @@ -156,8 +156,8 @@ export function AgentOverlay() { } try { const defaultModel = parseModelRef(values.defaultModel); - const selectedModel = models.find((model) => `${model.providerSlug}/${model.slug}` === values.defaultModel); - await client.updateAgent(target.slug, { + const selectedModel = models.find((model) => `${model.providerCode}/${model.code}` === values.defaultModel); + await client.updateAgent(target.code, { name: values.name.trim(), soul: values.soul.trim(), isDefault: values.isDefault === "true", @@ -177,7 +177,7 @@ export function AgentOverlay() { return; } try { - await client.deleteAgent(target.slug); + await client.deleteAgent(target.code); setToast(`Agent deleted: ${target.name}`); await reload(); } catch (e) { @@ -190,7 +190,7 @@ export function AgentOverlay() { if (!client) { return; } - if (currentAgent?.slug === target.slug) { + if (currentAgent?.code === target.code) { setToast("Already using this agent."); setOverlay(null); return; @@ -254,7 +254,7 @@ export function AgentOverlay() { ) : ( void handleSwitch(a)} @@ -272,7 +272,7 @@ export function AgentOverlay() { function AgentList({ agents, - currentAgentId, + currentAgentCode, cursor, onCursor, onSwitch, @@ -282,7 +282,7 @@ function AgentList({ onClose, }: { agents: AgentDto[]; - currentAgentId?: string; + currentAgentCode?: string; cursor: number; onCursor: (i: number) => void; onSwitch: (a: AgentDto) => void; @@ -352,11 +352,11 @@ function AgentList({ const i = agents.indexOf(a); const selected = i === cursor; const flags = - `${a.isDefault ? "[default] " : ""}${a.slug === currentAgentId ? "← current" : ""}`.trim(); + `${a.isDefault ? "[default] " : ""}${a.code === currentAgentCode ? "← current" : ""}`.trim(); const name = pad(a.name, nameWidth); return ( onCursor(i)} onMouseClick={() => { onCursor(i); @@ -384,7 +384,7 @@ function AgentList({ {trunc( - `${agents[cursor]?.isDefault ? "[default] " : ""}${agents[cursor]?.slug === currentAgentId ? "← current" : ""}`.trim() || "No flags", + `${agents[cursor]?.isDefault ? "[default] " : ""}${agents[cursor]?.code === currentAgentCode ? "← current" : ""}`.trim() || "No flags", dialogSize.width, )} diff --git a/packages/agenty-cli/src/components/ModelOverlay.test.ts b/packages/agenty-cli/src/components/ModelOverlay.test.ts new file mode 100644 index 0000000..10ed6ef --- /dev/null +++ b/packages/agenty-cli/src/components/ModelOverlay.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; + +import type { CoreModelDto, ModelProviderDto } from "../api/types"; +import { isBuiltinProvider } from "../consts/providerPresets"; +import { + initialModelIndex, + initialProviderIndex, + modelInputFromValues, + modelUpdateFromValues, + parseReasoningMapping, +} from "./ModelOverlay"; + +function model(code: string, isDefault = false): CoreModelDto { + return { + code, + name: code.toUpperCase(), + contextWindow: 128_000, + maxOutputTokens: 8_192, + multiModal: false, + light: false, + isDefault, + }; +} + +function provider(code: string, models: CoreModelDto[] = []): ModelProviderDto { + return { + code, + name: code, + type: "openai_completions", + baseUrl: "https://example.invalid/v1", + apiKey: "test-key", + models, + createdAt: "", + updatedAt: "", + }; +} + +describe("model overlay behavior", () => { + test("starts on the provider used by the current session", () => { + const providers = [provider("custom"), provider("openai")]; + + expect(initialProviderIndex(providers, "openai")).toBe(1); + expect(initialProviderIndex(providers, "missing")).toBe(0); + expect(isBuiltinProvider("openai")).toBe(true); + expect(isBuiltinProvider("custom")).toBe(false); + }); + + test("starts on the current model, then provider default, then first model", () => { + const models = [model("first"), model("default", true), model("current")]; + const target = provider("custom", models); + + expect(initialModelIndex(target, "current")).toBe(2); + expect(initialModelIndex(target, "missing")).toBe(1); + expect(initialModelIndex(provider("empty"))).toBe(0); + }); + + test("parses and validates reasoning mappings", () => { + expect(parseReasoningMapping("{\"fast\":\"low\",\"deep\":\"high\"}")).toEqual({ + fast: "low", + deep: "high", + }); + expect(parseReasoningMapping("{}")).toEqual({}); + expect(parseReasoningMapping(" ")).toBeUndefined(); + expect(() => parseReasoningMapping("[]")).toThrow("JSON object"); + expect(() => parseReasoningMapping("{\"fast\":\"unsupported\"}")).toThrow("Invalid reasoning effort"); + }); + + test("builds create and update payloads without changing model codes", () => { + const values = { + code: "org/model_name[v2]", + name: "Model v2", + contextWindow: "64000", + multiModal: "true", + light: "false", + isDefault: "true", + reasoningMapping: "{\"deep\":\"xhigh\"}", + }; + const created = modelInputFromValues("custom", values); + expect(created).toMatchObject({ + providerCode: "custom", + modelCode: "org/model_name[v2]", + contextWindow: 64_000, + multiModal: true, + isDefault: true, + reasoningEffortMapping: { deep: "xhigh" }, + }); + + const updated = modelUpdateFromValues(model("old-id"), { + ...values, + name: "Updated model", + code: "new-id", + }); + expect(updated).toMatchObject({ + name: "Updated model", + contextWindow: 64_000, + multiModal: true, + }); + }); + +}); diff --git a/packages/agenty-cli/src/components/ModelOverlay.tsx b/packages/agenty-cli/src/components/ModelOverlay.tsx new file mode 100644 index 0000000..a71b52a --- /dev/null +++ b/packages/agenty-cli/src/components/ModelOverlay.tsx @@ -0,0 +1,685 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { + CoreModelDto, + CreateModelDto, + ModelProviderDto, + ReasoningEffort, + UpdateModelDto, +} from "../api/types"; +import { isBuiltinProvider } from "../consts/providerPresets"; +import { useInput } from "../hooks/useInput"; +import { useAppStore } from "../state/store"; +import { useBottomDialogSize } from "./BottomDialog"; +import type { FormField } from "./FormPanel"; +import { FormPanel } from "./FormPanel"; +import { Box, Spinner, Text } from "./ui"; + +const REASONING_EFFORTS: readonly ReasoningEffort[] = [ + "off", + "low", + "medium", + "high", + "xhigh", + "max", +]; + +type Mode = + | { kind: "list" } + | { kind: "create"; provider: ModelProviderDto } + | { kind: "edit"; provider: ModelProviderDto; target: CoreModelDto } + | { kind: "confirm-delete"; provider: ModelProviderDto; target: CoreModelDto }; + +export function initialProviderIndex( + providers: ModelProviderDto[], + providerCode?: string, +): number { + if (providers.length === 0) { + return 0; + } + const index = providerCode + ? providers.findIndex((provider) => provider.code === providerCode) + : -1; + return index >= 0 ? index : 0; +} + +export function initialModelIndex( + provider: ModelProviderDto | undefined, + modelCode?: string, +): number { + const models = provider?.models ?? []; + if (models.length === 0) { + return 0; + } + const requested = modelCode + ? models.findIndex((model) => model.code === modelCode) + : -1; + if (requested >= 0) { + return requested; + } + const defaultIndex = models.findIndex((model) => model.isDefault); + return defaultIndex >= 0 ? defaultIndex : 0; +} + +export function parseReasoningMapping(raw: string): Record | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error("Reasoning mapping must be a JSON object."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Reasoning mapping must be a JSON object."); + } + + const mapping: Record = {}; + for (const [nativeEffort, agentyEffort] of Object.entries(parsed)) { + if (!nativeEffort.trim()) { + throw new Error("Reasoning mapping keys cannot be empty."); + } + if (typeof agentyEffort !== "string" || !REASONING_EFFORTS.includes(agentyEffort as ReasoningEffort)) { + throw new Error(`Invalid reasoning effort for ${nativeEffort}.`); + } + mapping[nativeEffort] = agentyEffort as ReasoningEffort; + } + return mapping; +} + +function serializeReasoningMapping(mapping?: Record): string { + return mapping && Object.keys(mapping).length > 0 ? JSON.stringify(mapping) : ""; +} + +function parsePositiveInteger(raw: string, label: string): number { + const value = Number(raw.trim()); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +export function modelInputFromValues( + providerCode: string, + values: Record, +): CreateModelDto { + const modelCode = values.code.trim(); + const name = values.name.trim(); + if (!modelCode) { + throw new Error("Model Code is required."); + } + if (!name) { + throw new Error("Model name is required."); + } + + return { + providerCode, + modelCode, + name, + contextWindow: parsePositiveInteger(values.contextWindow, "Context window"), + multiModal: values.multiModal === "true", + light: values.light === "true", + isDefault: values.isDefault === "true", + reasoningEffortMapping: parseReasoningMapping(values.reasoningMapping), + }; +} + +export function modelUpdateFromValues( + target: CoreModelDto, + values: Record, +): UpdateModelDto { + const input = modelInputFromValues(target.code, values); + return { + name: input.name, + contextWindow: input.contextWindow, + multiModal: input.multiModal, + light: input.light, + isDefault: input.isDefault, + reasoningEffortMapping: input.reasoningEffortMapping, + }; +} + +function modelFields(target?: CoreModelDto): FormField[] { + return [ + { + key: "code", + label: "Model Code", + kind: "text", + value: target?.code ?? "", + placeholder: "model-code or org/model-code", + readOnly: !!target, + }, + { + key: "name", + label: "Model name", + kind: "text", + value: target?.name ?? "", + placeholder: "Model name", + }, + { + key: "contextWindow", + label: "Context window", + kind: "text", + value: target ? String(target.contextWindow) : "128000", + placeholder: "128000", + }, + { + key: "multiModal", + label: "Multimodal", + kind: "boolean", + value: target?.multiModal ? "true" : "false", + }, + { + key: "light", + label: "Light", + kind: "boolean", + value: target?.light ? "true" : "false", + }, + { + key: "isDefault", + label: "Default", + kind: "boolean", + value: target?.isDefault ? "true" : "false", + }, + { + key: "reasoningMapping", + label: "Reasoning map", + kind: "text", + value: serializeReasoningMapping(target?.reasoningEffortMapping), + placeholder: "{\"native\":\"medium\"}", + }, + ]; +} + +export function ModelOverlay() { + const client = useAppStore((state) => state.client); + const sessionModel = useAppStore((state) => state.session?.currentModel); + const setToast = useAppStore((state) => state.setToast); + const setOverlay = useAppStore((state) => state.setOverlay); + const switchModel = useAppStore((state) => state.switchModel); + + const [providers, setProviders] = useState(null); + const [providerCursor, setProviderCursor] = useState(0); + const [modelCursor, setModelCursor] = useState(0); + const [mode, setMode] = useState({ kind: "list" }); + const modeRef = useRef(mode); + modeRef.current = mode; + const selectedProviderCodeRef = useRef(sessionModel?.providerCode); + const selectedModelCodeRef = useRef(sessionModel?.modelCode); + + const reload = useCallback(async () => { + if (!client) { + return; + } + try { + const list = await client.listProviders(); + const nextProviderIndex = initialProviderIndex( + list, + selectedProviderCodeRef.current, + ); + const nextProvider = list[nextProviderIndex]; + const nextModelIndex = initialModelIndex( + nextProvider, + selectedModelCodeRef.current, + ); + const nextModel = nextProvider?.models[nextModelIndex]; + selectedProviderCodeRef.current = nextProvider?.code; + selectedModelCodeRef.current = nextModel?.code; + setProviders(list); + setProviderCursor(nextProviderIndex); + setModelCursor(nextModelIndex); + } catch (error) { + setToast(`failed to load models: ${(error as Error).message}`, true); + setProviders([]); + } + }, [client, setToast]); + + useEffect(() => { + void reload(); + }, [reload]); + + const close = () => setOverlay(null); + + useInput((input, key) => { + if (modeRef.current.kind !== "list") { + return; + } + if (providers !== null && providers.length > 0) { + return; + } + if (key.escape) { + close(); + return; + } + if (input.toLowerCase() === "a") { + setToast("No provider is available for a new model.", true); + } + }); + + const selectProvider = (index: number) => { + const provider = providers?.[index]; + if (!provider) { + return; + } + selectedProviderCodeRef.current = provider.code; + const nextModelIndex = initialModelIndex(provider); + selectedModelCodeRef.current = provider.models[nextModelIndex]?.code; + setProviderCursor(index); + setModelCursor(nextModelIndex); + }; + + const selectModelCursor = (index: number) => { + const provider = providers?.[providerCursor]; + const model = provider?.models[index]; + if (model) { + selectedModelCodeRef.current = model.code; + } + setModelCursor(index); + }; + + const handleSwitch = async (model: CoreModelDto) => { + if (!client || !providers) { + return; + } + const provider = providers[providerCursor]; + if (!provider) { + return; + } + const projected = { + ...model, + providerCode: provider.code, + providerName: provider.name, + }; + await switchModel(projected); + }; + + const handleCreate = async (provider: ModelProviderDto, values: Record) => { + if (!client) { + return; + } + try { + const input = modelInputFromValues(provider.code, values); + const created = await client.createModel(input); + selectedModelCodeRef.current = created.code; + await reload(); + setMode({ kind: "list" }); + setToast(`Model added: ${provider.name} · ${created.name}`); + } catch (error) { + setToast(`add model failed: ${(error as Error).message}`, true); + } + }; + + const handleUpdate = async ( + provider: ModelProviderDto, + target: CoreModelDto, + values: Record, + ) => { + if (!client) { + return; + } + try { + const update = modelUpdateFromValues(target, values); + const updated = await client.updateModel(provider.code, target.code, update); + selectedModelCodeRef.current = updated.code; + await reload(); + setMode({ kind: "list" }); + setToast(`Model updated: ${provider.name} · ${updated.name}`); + } catch (error) { + setToast(`update model failed: ${(error as Error).message}`, true); + } + }; + + const handleDelete = async (provider: ModelProviderDto, target: CoreModelDto) => { + if (!client) { + return; + } + try { + await client.deleteModel(provider.code, target.code); + selectedModelCodeRef.current = undefined; + await reload(); + setMode({ kind: "list" }); + setToast(`Model deleted: ${provider.name} · ${target.name}`); + } catch (error) { + setToast(`delete model failed: ${(error as Error).message}`, true); + } + }; + + if (mode.kind === "create") { + return ( + void handleCreate(mode.provider, values)} + onClose={() => setMode({ kind: "list" })} + /> + ); + } + + if (mode.kind === "edit") { + return ( + void handleUpdate(mode.provider, mode.target, values)} + onClose={() => setMode({ kind: "list" })} + /> + ); + } + + if (mode.kind === "confirm-delete") { + return ( + void handleDelete(mode.provider, mode.target)} + onCancel={() => setMode({ kind: "list" })} + /> + ); + } + + return ( + + + Models + + {providers === null ? ( + + ) : providers.length === 0 ? ( + No providers configured. + ) : ( + void handleSwitch(model)} + onAdd={(provider) => setMode({ kind: "create", provider })} + onEdit={(provider, target) => setMode({ kind: "edit", provider, target })} + onDelete={(provider, target) => setMode({ kind: "confirm-delete", provider, target })} + onClose={close} + /> + )} + + ); +} + +function ModelForm({ + title, + fields, + onSave, + onClose, +}: { + title: string; + fields: FormField[]; + onSave: (values: Record) => void; + onClose: () => void; +}) { + return ( + { + if (action === "save") { + onSave(values); + } else { + onClose(); + } + }} + onClose={onClose} + /> + ); +} + +function ModelList({ + providers, + providerCursor, + modelCursor, + currentModel, + onProviderCursor, + onModelCursor, + onSwitch, + onAdd, + onEdit, + onDelete, + onClose, +}: { + providers: ModelProviderDto[]; + providerCursor: number; + modelCursor: number; + currentModel?: { providerCode: string; modelCode: string }; + onProviderCursor: (index: number) => void; + onModelCursor: (index: number) => void; + onSwitch: (model: CoreModelDto) => void; + onAdd: (provider: ModelProviderDto) => void; + onEdit: (provider: ModelProviderDto, model: CoreModelDto) => void; + onDelete: (provider: ModelProviderDto, model: CoreModelDto) => void; + onClose: () => void; +}) { + const dialogSize = useBottomDialogSize(); + const provider = providers[providerCursor]; + const models = provider?.models ?? []; + const manageable = provider ? !isBuiltinProvider(provider) : false; + const compact = dialogSize.width < 66; + const providerWidth = compact + ? Math.max(dialogSize.width - 16, 16) + : Math.max(Math.min(Math.floor(dialogSize.width * 0.34), 28), 18); + const modelWidth = compact + ? 0 + : Math.max(dialogSize.width - providerWidth - 22, 18); + const maxVisible = Math.max(dialogSize.height - 8, 1); + const maxVis = Math.min(maxVisible, models.length); + const half = Math.floor(maxVis / 2); + let start = modelCursor - half; + if (start < 0) { + start = 0; + } + if (start + maxVis > models.length) { + start = Math.max(models.length - maxVis, 0); + } + const visible = models.slice(start, start + maxVis); + + useInput((input, key) => { + if (key.escape) { + onClose(); + return; + } + if (key.leftArrow || key.rightArrow) { + const direction = key.leftArrow ? -1 : 1; + const next = (providerCursor + direction + providers.length) % providers.length; + onProviderCursor(next); + return; + } + if (key.upArrow) { + onModelCursor(Math.max(modelCursor - 1, 0)); + return; + } + if (key.downArrow) { + onModelCursor(Math.min(modelCursor + 1, Math.max(models.length - 1, 0))); + return; + } + + const model = models[modelCursor]; + const lower = input.toLowerCase(); + if ((key.return || lower === "s") && model) { + onSwitch(model); + return; + } + if (!provider || !manageable) { + return; + } + if (lower === "a") { + onAdd(provider); + } else if (lower === "e" && model) { + onEdit(provider, model); + } else if (lower === "d" && model) { + onDelete(provider, model); + } + }); + + return ( + + + Provider: + { + const next = (providerCursor - 1 + providers.length) % providers.length; + onProviderCursor(next); + }}> + ‹ + + {provider?.name ?? "—"} + { + const next = (providerCursor + 1) % providers.length; + onProviderCursor(next); + }}> + › + + + {provider ? ` · ${manageable ? "custom" : "built-in"}` : ""} + + + + + {provider + ? ` ${compact ? "Model" : `${pad("Model", modelWidth)} ${pad("Context", 10)}`} ${models.length} model${models.length === 1 ? "" : "s"}` + : "No provider selected"} + + + {models.length === 0 ? ( + + + {manageable + ? "No models. Press `a` to add one." + : "No models available for this built-in provider."} + + + ) : ( + + {visible.map((model) => { + const index = models.indexOf(model); + const selected = index === modelCursor; + const current = currentModel?.providerCode === provider?.code && + currentModel.modelCode === model.code; + const label = model.name && model.code + ? `${model.name} · ${model.code}` + : model.code; + return ( + onModelCursor(index)} + onMouseClick={() => onSwitch(model)} + > + + {selected ? "❯" : " "} + + + + + {label} + + + {compact ? null : } + {compact ? null : ( + + {pad(model.contextWindow.toLocaleString(), 10)} + + )} + + {current ? " · current" : model.isDefault ? " · default" : ""} + + + ); + })} + + )} + + {manageable ? ( + <> + provider && onAdd(provider)}>Add + { + const model = models[modelCursor]; + if (provider && model) { + onEdit(provider, model); + } + }}>Edit + { + const model = models[modelCursor]; + if (provider && model) { + onDelete(provider, model); + } + }}>Delete + + ) : ( + Built-in provider · switching only + )} + + + + {manageable + ? "←→ provider · ↑↓ model · Enter/s switch · a add · e edit · d delete · Esc back" + : "←→ provider · ↑↓ model · Enter/s switch · Esc back"} + + + + ); +} + +function DeleteConfirm({ + target, + onConfirm, + onCancel, +}: { + target: CoreModelDto; + onConfirm: () => void; + onCancel: () => void; +}) { + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + const lower = input.toLowerCase(); + if (lower === "y") { + onConfirm(); + } else if (lower === "n") { + onCancel(); + } + }); + + return ( + + + Delete model "{target.name}"? + + This cannot be undone. + + [Delete] + [Cancel] + + + ); +} + +function trunc(value: string, width: number): string { + if (width <= 0) { + return ""; + } + if (value.length <= width) { + return value; + } + if (width === 1) { + return "…"; + } + return value.slice(0, width - 1) + "…"; +} + +function pad(value: string, width: number): string { + const clipped = trunc(value, width); + return clipped + " ".repeat(Math.max(width - clipped.length, 0)); +} diff --git a/packages/agenty-cli/src/components/ProviderOverlay.tsx b/packages/agenty-cli/src/components/ProviderOverlay.tsx index b70809b..9b949f4 100644 --- a/packages/agenty-cli/src/components/ProviderOverlay.tsx +++ b/packages/agenty-cli/src/components/ProviderOverlay.tsx @@ -14,7 +14,7 @@ const PROVIDER_TYPE_OPTIONS = providerTypes.map((t) => ({ label: t, value: t })) function buildCreateFields(formType: string): FormField[] { const baseUrl = providerDefaultBaseURLs[formType] ?? ""; return [ - { key: "slug", label: "Slug", kind: "text" as const, value: "", placeholder: "my-provider" }, + { key: "code", label: "Provider Code", kind: "text" as const, value: "", placeholder: "my-provider" }, { key: "name", label: "Name", kind: "text" as const, value: "", placeholder: "my-provider" }, { key: "type", label: "Type", kind: "select" as const, value: formType, options: PROVIDER_TYPE_OPTIONS }, { key: "baseUrl", label: "Base URL", kind: "text" as const, value: baseUrl }, @@ -24,7 +24,7 @@ function buildCreateFields(formType: string): FormField[] { function buildEditFields(target: ModelProviderDto): FormField[] { return [ - { key: "slug", label: "Slug", kind: "text" as const, value: target.slug, readOnly: true }, + { key: "code", label: "Provider Code", kind: "text" as const, value: target.code, readOnly: true }, { key: "name", label: "Name", kind: "text" as const, value: target.name, placeholder: target.name }, { key: "type", label: "Type", kind: "select" as const, value: target.type, options: PROVIDER_TYPE_OPTIONS }, { key: "baseUrl", label: "Base URL", kind: "text" as const, value: target.baseUrl }, @@ -114,7 +114,7 @@ export function ProviderOverlay() { } try { await client.createProvider({ - slug: values.slug.trim(), + code: values.code.trim(), name: values.name.trim(), type: values.type as APIType, baseUrl: values.baseUrl.trim(), @@ -141,7 +141,7 @@ export function ProviderOverlay() { if (values.apiKey && values.apiKey.trim() !== "") { dto.apiKey = values.apiKey; } - await client.updateProvider(target.slug, dto); + await client.updateProvider(target.code, dto); setToast(`Provider updated: ${values.name.trim()}`); await reload(); } catch (e) { @@ -155,7 +155,7 @@ export function ProviderOverlay() { return; } try { - await client.deleteProvider(target.slug); + await client.deleteProvider(target.code); setToast(`Provider deleted: ${target.name}`); await reload(); } catch (e) { @@ -321,7 +321,7 @@ function ProviderList({ const url = pad(p.baseUrl, urlWidth); return ( onCursor(i)} onMouseClick={() => { onCursor(i); diff --git a/packages/agenty-cli/src/components/StatusOverlay.tsx b/packages/agenty-cli/src/components/StatusOverlay.tsx index 8e45db9..c6a7825 100644 --- a/packages/agenty-cli/src/components/StatusOverlay.tsx +++ b/packages/agenty-cli/src/components/StatusOverlay.tsx @@ -46,7 +46,7 @@ export function StatusOverlay() { const rows: [string, string][] = [ ["Session", session?.id ?? "?"], ["Agent", agent?.name ?? "?"], - ["Model", `${model?.providerName ?? "?"}/${model?.name ?? "?"}`], + ["Model", `${model?.providerName ?? "?"} · ${model?.name ?? "?"}`], ["Thinking", thinking], ["Messages", String(history.length)], ["Context", `${session?.contextWindow ?? 0}/${tokenConsumed}`], diff --git a/packages/agenty-cli/src/components/WizardOverlay.tsx b/packages/agenty-cli/src/components/WizardOverlay.tsx index 2d58cce..31dafc2 100644 --- a/packages/agenty-cli/src/components/WizardOverlay.tsx +++ b/packages/agenty-cli/src/components/WizardOverlay.tsx @@ -28,7 +28,7 @@ import { } from "./wizardNavigation"; import { persistWizardSetup, - selectedModelId, + selectedModelCode, validateWizardDrafts, } from "./wizardSetup"; @@ -82,10 +82,10 @@ function providerFields(draft: ProviderDraft): FormField[] { placeholder: "My provider", }, { - key: "slug", - label: "Provider slug", + key: "code", + label: "Provider Code", kind: "text", - value: draft.slug, + value: draft.code, placeholder: "my-provider", readOnly: draft.source === "preset", }, @@ -118,11 +118,11 @@ function providerFields(draft: ProviderDraft): FormField[] { function modelFields(model: ModelDraft): FormField[] { return [ { - key: "slug", - label: "Model ID", + key: "code", + label: "Model Code", kind: "text", - value: model.slug, - placeholder: "model-id or org/model-id", + value: model.code, + placeholder: "model-code or org/model-code", }, { key: "name", @@ -155,7 +155,7 @@ function rowsForDrafts(drafts: ProviderDraft[]): ProviderRow[] { .map((draft) => ({ kind: "custom" as const, draft, - label: draft.name || draft.slug || "Custom provider", + label: draft.name || draft.code || "Custom provider", description: providerTypeLabel(draft.type), })), ); @@ -203,9 +203,9 @@ function WizardContent() { if (cancelled) { return; } - const knownPresetSlugs = new Set(providerPresets.map((preset) => preset.slug)); + const knownPresetCodes = new Set(providerPresets.map((preset) => preset.code)); const restored = (providers ?? []).map((provider) => { - const preset = providerPresets.find((candidate) => candidate.slug === provider.slug); + const preset = providerPresets.find((candidate) => candidate.code === provider.code); const draft = draftForProvider(provider, preset); return { draft, @@ -213,7 +213,7 @@ function WizardContent() { }; }); const configured = restored.filter(({ draft }) => - draft.source === "custom" || knownPresetSlugs.has(draft.slug), + draft.source === "custom" || knownPresetCodes.has(draft.code), ); setDrafts(configured.map(({ draft }) => draft)); setModels(configured.map(({ model }) => model)); @@ -268,16 +268,16 @@ function WizardContent() { const next: ProviderDraft = { ...editing, name: values.name.trim(), - slug: values.slug.trim(), + code: values.code.trim(), type: values.type, baseUrl: values.baseUrl.trim(), apiKey: values.apiKey.trim(), }; const duplicate = drafts.some( - (draft) => draft.id !== next.id && draft.slug.trim() !== "" && draft.slug === next.slug, + (draft) => draft.id !== next.id && draft.code.trim() !== "" && draft.code === next.code, ); if (duplicate) { - setError(`Provider slug already configured: ${next.slug}`); + setError(`Provider code already configured: ${next.code}`); return; } const validationError = validateProviderDraft(next); @@ -301,7 +301,7 @@ function WizardContent() { return [...current, modelDraftForProvider(next, preset)]; } return current.map((model, modelIndex) => modelIndex === index - ? { ...model, providerSlug: next.slug, providerName: next.name } + ? { ...model, providerCode: next.code, providerName: next.name } : model); }); setProviderFocus({ kind: "row", index: 0 }); @@ -328,7 +328,7 @@ function WizardContent() { } const next: ModelDraft = { ...editingModel, - slug: values.slug.trim(), + code: values.code.trim(), name: values.name.trim(), contextWindow: Number(values.contextWindow), }; @@ -398,7 +398,7 @@ function WizardContent() { {error ? {error} : null} { @@ -431,7 +431,7 @@ function WizardContent() { setError(null); setStep("model-form"); }} - onConfirm={(model) => void saveSetup(selectedModelId(model))} + onConfirm={(model) => void saveSetup(selectedModelCode(model))} onBack={() => { setError(null); setStep("providers"); @@ -494,7 +494,7 @@ function WelcomeStep({ onBegin, onExit }: { onBegin: () => void; onExit: () => v 02 Model details - Enter the model ID and limits used by the core loop. + Enter the model code and limits used by the core loop. 03 Default agent @@ -796,8 +796,8 @@ function ModelStep({ {models.map((model, index) => { const active = focus.kind === "row" && index === focus.index; - const provider = `${model.providerName} (${model.providerSlug})`; - const modelLabel = model.name && model.slug ? `${model.name} · ${model.slug}` : "Configure model"; + const provider = `${model.providerName} (${model.providerCode})`; + const modelLabel = model.name && model.code ? `${model.name} · ${model.code}` : "Configure model"; return ( { }); }); + test("summarizes native and repeated custom apply patch operations in order", () => { + const nativeDisplay = buildToolDisplay(toolCall( + "apply_patch", + { operation: { type: "update_file", path: "src/main.go", diff: "@@\n-old\n+new" } }, + JSON.stringify({ operations: [{ type: "update_file", path: "src/main.go" }] }), + )); + expect(nativeDisplay).toMatchObject({ + label: "Apply patch", + status: "success", + summaryLines: ["update src/main.go"], + }); + + const customDisplay = buildToolDisplay(toolCall("apply_patch", { + patch: [ + "*** Begin Patch", + "*** Update File: notes.txt", + "@@", + "-one", + "+two", + "*** Update File: notes.txt", + "@@", + "-two", + "+three", + "*** Delete File: old.txt", + "*** End Patch", + ].join("\n"), + })); + expect(customDisplay.summaryLines).toEqual([ + "update notes.txt", + "update notes.txt", + "delete old.txt", + ]); + }); + test("marks a shell output that ends with a newline", () => { const display = buildToolDisplay(toolCall( "shell", diff --git a/packages/agenty-cli/src/components/toolDisplay.ts b/packages/agenty-cli/src/components/toolDisplay.ts index 407569b..21c84b2 100644 --- a/packages/agenty-cli/src/components/toolDisplay.ts +++ b/packages/agenty-cli/src/components/toolDisplay.ts @@ -37,6 +37,7 @@ const TOOL_LABELS: Record = { glob: "Find files", ls: "List directory", shell: "Run shell", + apply_patch: "Apply patch", }; function isRecord(value: unknown): value is JsonRecord { @@ -442,6 +443,65 @@ function shellDisplay( }; } +function applyPatchDisplay(input: JsonRecord | undefined, result: ToolResult | undefined): ToolDisplay { + const operation = isRecord(input?.operation) ? input.operation : undefined; + const patch = stringValue(input?.patch); + const summaryLines: string[] = []; + if (operation) { + summaryLines.push(formatPatchOperation(operation)); + } else if (patch) { + for (const line of splitLines(patch)) { + const summary = formatPatchEnvelopeHeader(line); + if (summary) { + summaryLines.push(summary); + } + } + } + if (summaryLines.length === 0) { + summaryLines.push("patch"); + } + const visibleSummary = summaryLines.slice(0, MAX_SUMMARY_LINES); + if (summaryLines.length > visibleSummary.length) { + visibleSummary.push(`… ${summaryLines.length - visibleSummary.length} more operations`); + } + return { + label: TOOL_LABELS.apply_patch, + status: toolStatus(result), + summaryLines: visibleSummary, + detailLines: formatResultPreview(result), + }; +} + +function formatPatchOperation(operation: JsonRecord): string { + const path = formatPath(operation.path); + switch (operation.type) { + case "create_file": + return `create ${path}`; + case "delete_file": + return `delete ${path}`; + case "update_file": { + const moveTo = stringValue(operation.moveTo); + return moveTo ? `update ${path} → ${formatPath(moveTo)}` : `update ${path}`; + } + default: + return `modify ${path}`; + } +} + +function formatPatchEnvelopeHeader(line: string): string { + const headers: Array<[string, string]> = [ + ["*** Add File:", "create"], + ["*** Update File:", "update"], + ["*** Delete File:", "delete"], + ]; + for (const [prefix, action] of headers) { + if (line.startsWith(prefix)) { + return `${action} ${formatPath(line.slice(prefix.length).trim())}`; + } + } + return ""; +} + function unknownDisplay(name: string, input: JsonRecord | undefined, rawArguments: string, result: ToolResult | undefined): ToolDisplay { const details = formatResultPreview(result); const resultSummary = result @@ -475,6 +535,8 @@ export function buildToolDisplay(toolCall: UIToolCall, expanded = true): ToolDis return listDisplay(input, toolCall.result); case "shell": return shellDisplay(input, toolCall.result, expanded); + case "apply_patch": + return applyPatchDisplay(input, toolCall.result); default: return unknownDisplay(toolCall.name, input, toolCall.arguments, toolCall.result); } diff --git a/packages/agenty-cli/src/components/wizardSetup.test.ts b/packages/agenty-cli/src/components/wizardSetup.test.ts index 4af8a1f..4b6cf59 100644 --- a/packages/agenty-cli/src/components/wizardSetup.test.ts +++ b/packages/agenty-cli/src/components/wizardSetup.test.ts @@ -12,7 +12,7 @@ import { } from "../consts/providerPresets"; import { persistWizardSetup, - selectedModelId, + selectedModelCode, type WizardSetupClient, } from "./wizardSetup"; @@ -27,10 +27,10 @@ function createModel(draft: ProviderDraft): ModelDraft { return modelDraftForProvider(draft, providerPresets[0]); } -function createAgent(slug: string, isDefault: boolean): AgentDto { +function createAgent(code: string, isDefault: boolean): AgentDto { return { - slug, - name: slug, + code, + name: code, soul: "", defaultContextWindow: 128_000, isDefault, @@ -41,13 +41,13 @@ function createAgent(slug: string, isDefault: boolean): AgentDto { function createProvider(draft: ProviderDraft, model: ModelDraft = createModel(draft)): ModelProviderDto { return { - slug: draft.slug, + code: draft.code, name: draft.name, type: draft.type, baseUrl: draft.baseUrl, apiKey: draft.apiKey, models: [{ - slug: model.slug, + code: model.code, name: model.name, contextWindow: model.contextWindow, maxOutputTokens: 8_192, @@ -114,7 +114,7 @@ describe("first-run provider setup", () => { "anthropic", "gemini", ]); - expect(providerPresets.every((preset) => preset.model.slug.length > 0)).toBe(true); + expect(providerPresets.every((preset) => preset.model.code.length > 0)).toBe(true); }); test("restores an existing provider and its preferred model", () => { @@ -127,7 +127,7 @@ describe("first-run provider setup", () => { baseUrl: "https://gateway.example/v1", models: [{ ...createProvider(draft, existingModel).models[0], - slug: "gateway-model", + code: "gateway-model", name: "Gateway model", isDefault: true, }], @@ -137,7 +137,7 @@ describe("first-run provider setup", () => { expect(restoredProvider.name).toBe("OpenAI gateway"); expect(restoredProvider.baseUrl).toBe("https://gateway.example/v1"); - expect(restoredModel.slug).toBe("gateway-model"); + expect(restoredModel.code).toBe("gateway-model"); }); test("keeps provider validation separate from model validation", () => { @@ -147,7 +147,7 @@ describe("first-run provider setup", () => { expect(validateProviderDraft({ ...draft, apiKey: "" })).toContain("API key"); expect(validateProviderDraft(draft)).toBeNull(); expect(validateModelDraft({ ...model, contextWindow: 0 })).toContain("Context window"); - expect(validateModelDraft({ ...model, slug: "org/model_name[v2]" })).toBeNull(); + expect(validateModelDraft({ ...model, code: "org/model_name[v2]" })).toBeNull(); }); test("creates resources in the core initialization order", async () => { @@ -155,7 +155,7 @@ describe("first-run provider setup", () => { const model = createModel(draft); const client = fakeClient(); - await persistWizardSetup(client, [draft], [model], selectedModelId(model)); + await persistWizardSetup(client, [draft], [model], selectedModelCode(model)); expect(client.calls).toEqual([ "provider.list", @@ -172,7 +172,7 @@ describe("first-run provider setup", () => { const model = createModel(draft); const client = fakeClient([createProvider(draft, model)], [createAgent("default", true)]); - await persistWizardSetup(client, [draft], [model], selectedModelId(model)); + await persistWizardSetup(client, [draft], [model], selectedModelCode(model)); expect(client.calls).toEqual([ "provider.list", diff --git a/packages/agenty-cli/src/components/wizardSetup.ts b/packages/agenty-cli/src/components/wizardSetup.ts index 421fd8a..a2b3474 100644 --- a/packages/agenty-cli/src/components/wizardSetup.ts +++ b/packages/agenty-cli/src/components/wizardSetup.ts @@ -13,7 +13,7 @@ import { validateProviderDraft, } from "../consts/providerPresets"; -const DEFAULT_AGENT_SLUG = "default"; +const DEFAULT_AGENT_CODE = "default"; const DEFAULT_AGENT_NAME = "Default"; const DEFAULT_AGENT_SOUL = "Be helpful, concise, and accurate."; @@ -21,34 +21,34 @@ export interface WizardSetupClient { listProviders(): Promise; listAgents(): Promise; createProvider(input: CreateModelProviderDto): Promise; - updateProvider(slug: string, input: UpdateModelProviderDto): Promise; + updateProvider(code: string, input: UpdateModelProviderDto): Promise; createModel(input: { - providerSlug: string; - modelSlug: string; + providerCode: string; + modelCode: string; name: string; contextWindow?: number; reasoningEffortMapping?: Record; isDefault?: boolean; }): Promise; createAgent(input: { - slug: string; + code: string; name: string; soul?: string; - defaultModel?: { providerSlug: string; modelSlug: string }; + defaultModel?: { providerCode: string; modelCode: string }; defaultContextWindow?: number; defaultReasoningEffort?: ReasoningEffort; isDefault?: boolean; }): Promise; - updateAgent(slug: string, input: UpdateAgentDto): Promise; + updateAgent(code: string, input: UpdateAgentDto): Promise; completeInitialization(input: { - agentSlug: string; - providerSlug: string; - modelSlug: string; + agentCode: string; + providerCode: string; + modelCode: string; }): Promise<{ initialized: boolean }>; } -export function selectedModelId(model: ModelDraft): string { - return `${model.providerId}:${model.slug.trim()}`; +export function selectedModelCode(model: ModelDraft): string { + return `${model.providerId}:${model.code.trim()}`; } export function validateWizardDrafts( @@ -63,21 +63,21 @@ export function validateWizardDrafts( return "Configure one model for each provider to continue."; } - const providerSlugs = new Set(); + const providerCodes = new Set(); const providerIds = new Set(drafts.map((draft) => draft.id)); for (const draft of drafts) { const providerError = validateProviderDraft(draft); if (providerError) { return providerError; } - const slug = draft.slug.trim(); - if (providerSlugs.has(slug)) { - return `Provider slug already configured: ${slug}`; + const code = draft.code.trim(); + if (providerCodes.has(code)) { + return `Provider code already configured: ${code}`; } - providerSlugs.add(slug); + providerCodes.add(code); } - const modelIds = new Set(); + const modelCodes = new Set(); for (const model of models) { if (!providerIds.has(model.providerId)) { return "A model is attached to an unknown provider."; @@ -86,14 +86,14 @@ export function validateWizardDrafts( if (modelError) { return modelError; } - const modelId = selectedModelId(model); - if (modelIds.has(modelId)) { - return `Model ID already configured: ${model.slug.trim()}`; + const modelCode = selectedModelCode(model); + if (modelCodes.has(modelCode)) { + return `Model Code already configured: ${model.code.trim()}`; } - modelIds.add(modelId); + modelCodes.add(modelCode); } - if (!modelIds.has(selectedId)) { + if (!modelCodes.has(selectedId)) { return "Select a default model to continue."; } return null; @@ -118,20 +118,20 @@ export async function persistWizardSetup( for (const draft of drafts) { const model = modelsByProvider.get(draft.id); if (!model) { - throw new Error(`No model configured for provider ${draft.name || draft.slug}.`); + throw new Error(`No model configured for provider ${draft.name || draft.code}.`); } - const providerSlug = draft.slug.trim(); + const providerCode = draft.code.trim(); const providerInput: CreateModelProviderDto = { - slug: providerSlug, + code: providerCode, name: draft.name.trim(), type: draft.type, baseUrl: draft.baseUrl.trim(), apiKey: draft.apiKey.trim(), }; - const existing = existingProviders.find((provider) => provider.slug === providerSlug); + const existing = existingProviders.find((provider) => provider.code === providerCode); if (existing) { - await client.updateProvider(providerSlug, { + await client.updateProvider(providerCode, { name: providerInput.name, type: providerInput.type, baseUrl: providerInput.baseUrl, @@ -141,14 +141,14 @@ export async function persistWizardSetup( await client.createProvider(providerInput); } - const modelSlug = model.slug.trim(); - const isSelected = selectedModelId(model) === selectedId; + const modelCode = model.code.trim(); + const isSelected = selectedModelCode(model) === selectedId; if (isSelected) { selectedModel = model; } await client.createModel({ - providerSlug, - modelSlug, + providerCode, + modelCode, name: model.name.trim(), contextWindow: model.contextWindow, reasoningEffortMapping: model.reasoningEffortMapping, @@ -165,12 +165,12 @@ export async function persistWizardSetup( throw new Error("Selected model provider is missing."); } const defaultModel = { - providerSlug: selectedProvider.slug.trim(), - modelSlug: selectedModel.slug.trim(), + providerCode: selectedProvider.code.trim(), + modelCode: selectedModel.code.trim(), }; - const existingAgent = existingAgents.find((agent) => agent.slug === DEFAULT_AGENT_SLUG) ?? + const existingAgent = existingAgents.find((agent) => agent.code === DEFAULT_AGENT_CODE) ?? existingAgents.find((agent) => agent.isDefault); - let agentSlug = DEFAULT_AGENT_SLUG; + let agentCode = DEFAULT_AGENT_CODE; const agentInput = { name: DEFAULT_AGENT_NAME, soul: DEFAULT_AGENT_SOUL, @@ -180,15 +180,15 @@ export async function persistWizardSetup( isDefault: true, }; if (existingAgent) { - agentSlug = existingAgent.slug; - await client.updateAgent(agentSlug, agentInput); + agentCode = existingAgent.code; + await client.updateAgent(agentCode, agentInput); } else { - await client.createAgent({ slug: DEFAULT_AGENT_SLUG, ...agentInput }); + await client.createAgent({ code: DEFAULT_AGENT_CODE, ...agentInput }); } await client.completeInitialization({ - agentSlug, - providerSlug: defaultModel.providerSlug, - modelSlug: defaultModel.modelSlug, + agentCode, + providerCode: defaultModel.providerCode, + modelCode: defaultModel.modelCode, }); } diff --git a/packages/agenty-cli/src/consts/providerPresets.ts b/packages/agenty-cli/src/consts/providerPresets.ts index fad65e8..f12c376 100644 --- a/packages/agenty-cli/src/consts/providerPresets.ts +++ b/packages/agenty-cli/src/consts/providerPresets.ts @@ -1,7 +1,7 @@ import type { APIType, CoreModelDto, ModelProviderDto, ReasoningEffort } from "../api/types"; export interface ModelPreset { - slug: string; + code: string; name: string; contextWindow: number; reasoningEffortMapping?: Record; @@ -11,7 +11,7 @@ export interface ProviderPreset { key: string; label: string; description: string; - slug: string; + code: string; name: string; type: APIType; baseUrl: string; @@ -23,12 +23,12 @@ export const providerPresets: readonly ProviderPreset[] = [ key: "openai", label: "OpenAI", description: "Responses API", - slug: "openai", + code: "openai", name: "OpenAI", type: "openai", baseUrl: "https://api.openai.com/v1", model: { - slug: "gpt-5-mini", + code: "gpt-5-mini", name: "GPT-5 mini", contextWindow: 128_000, reasoningEffortMapping: { @@ -43,12 +43,12 @@ export const providerPresets: readonly ProviderPreset[] = [ key: "anthropic", label: "Anthropic", description: "Messages API", - slug: "anthropic", + code: "anthropic", name: "Anthropic", type: "anthropic", baseUrl: "https://api.anthropic.com", model: { - slug: "claude-haiku-4-5", + code: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextWindow: 200_000, reasoningEffortMapping: { @@ -63,12 +63,12 @@ export const providerPresets: readonly ProviderPreset[] = [ key: "google", label: "Google", description: "Gemini API", - slug: "google", + code: "google", name: "Google", type: "gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", model: { - slug: "gemini-2.5-flash", + code: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextWindow: 128_000, reasoningEffortMapping: { @@ -80,6 +80,11 @@ export const providerPresets: readonly ProviderPreset[] = [ }, ]; +export function isBuiltinProvider(provider: Pick | string): boolean { + const code = typeof provider === "string" ? provider : provider.code; + return providerPresets.some((preset) => preset.code === code); +} + export const compatibleProviderTypes: readonly { label: string; value: APIType }[] = [ { label: "OpenAI Responses API", value: "openai" }, { label: "OpenAI Chat Completions", value: "openai_completions" }, @@ -91,7 +96,7 @@ export interface ProviderDraft { id: string; source: "preset" | "custom"; presetKey?: string; - slug: string; + code: string; name: string; type: APIType; baseUrl: string; @@ -101,9 +106,9 @@ export interface ProviderDraft { export interface ModelDraft { id: string; providerId: string; - providerSlug: string; + providerCode: string; providerName: string; - slug: string; + code: string; name: string; contextWindow: number; reasoningEffortMapping?: Record; @@ -122,7 +127,7 @@ export function createPresetDraft( id: `preset:${preset.key}`, source: "preset", presetKey: preset.key, - slug: existing?.slug ?? preset.slug, + code: existing?.code ?? preset.code, name: existing?.name ?? preset.name, type: existing?.type ?? preset.type, baseUrl: existing?.baseUrl ?? preset.baseUrl, @@ -137,7 +142,7 @@ export function createCustomDraft( return { id, source: "custom", - slug: existing?.slug ?? "", + code: existing?.code ?? "", name: existing?.name ?? "", type: existing?.type ?? "openai_completions", baseUrl: existing?.baseUrl ?? "", @@ -152,7 +157,7 @@ export function draftForProvider( if (preset) { return createPresetDraft(preset, provider); } - return createCustomDraft(`provider:${provider.slug}`, provider); + return createCustomDraft(`provider:${provider.code}`, provider); } export function modelDraftForProvider( @@ -165,9 +170,9 @@ export function modelDraftForProvider( return { id: `${provider.id}:model`, providerId: provider.id, - providerSlug: provider.slug, + providerCode: provider.code, providerName: provider.name, - slug: model?.slug ?? fallback?.slug ?? "", + code: model?.code ?? fallback?.code ?? "", name: model?.name ?? fallback?.name ?? "", contextWindow: model?.contextWindow ?? fallback?.contextWindow ?? 128_000, reasoningEffortMapping: model?.reasoningEffortMapping ?? fallback?.reasoningEffortMapping, @@ -175,8 +180,8 @@ export function modelDraftForProvider( } export function validateProviderDraft(draft: ProviderDraft): string | null { - if (!draft.slug.trim()) { - return "Provider slug is required."; + if (!draft.code.trim()) { + return "Provider code is required."; } if (!draft.name.trim()) { return "Provider name is required."; @@ -191,11 +196,11 @@ export function validateProviderDraft(draft: ProviderDraft): string | null { } export function validateModelDraft(draft: ModelDraft): string | null { - if (!draft.slug.trim()) { - return `Model ID is required for ${draft.providerName.trim() || draft.providerSlug}.`; + if (!draft.code.trim()) { + return `Model Code is required for ${draft.providerName.trim() || draft.providerCode}.`; } if (!draft.name.trim()) { - return `Model name is required for ${draft.providerName.trim() || draft.providerSlug}.`; + return `Model name is required for ${draft.providerName.trim() || draft.providerCode}.`; } if (!Number.isSafeInteger(draft.contextWindow) || draft.contextWindow <= 0) { return `Context window for ${draft.name.trim()} must be a positive integer.`; diff --git a/packages/agenty-cli/src/state/store.test.ts b/packages/agenty-cli/src/state/store.test.ts index 118f778..0f5665f 100644 --- a/packages/agenty-cli/src/state/store.test.ts +++ b/packages/agenty-cli/src/state/store.test.ts @@ -6,8 +6,8 @@ import { useAppStore } from "./store"; const session: ChatSessionDto = { id: "session-1", - agentSlug: "default", - currentModel: { providerSlug: "provider", modelSlug: "model" }, + agentCode: "default", + currentModel: { providerCode: "provider", modelCode: "model" }, contextWindow: 32_000, rounds: [], createdAt: "2026-01-01T00:00:00Z", @@ -255,8 +255,8 @@ describe("chat tool event projection", () => { }, async resolveModel() { return { - slug: "model", - providerSlug: "provider", + code: "model", + providerCode: "provider", providerName: "Provider", name: "Model", contextWindow: 32_000, @@ -285,4 +285,91 @@ describe("chat tool event projection", () => { }); expect(call?.result?.content).toContain("shell_call_output"); }); + + test("projects persisted apply patch calls and attaches results", async () => { + const persisted: ChatSessionDto = { + ...session, + rounds: [{ + id: "round-patch", + sessionId: session.id, + sequence: 1, + status: "completed", + model: session.currentModel!, + contextWindow: session.contextWindow, + messages: [ + { + id: "assistant-patch", + roundId: "round-patch", + role: "assistant", + content: [{ + type: "apply_patch_call", + id: "apc-1", + callId: "call-patch", + source: "native", + operation: { + type: "update_file", + path: "notes.txt", + diff: "@@\n-old\n+new", + }, + }], + createdAt: "2026-01-01T00:00:01Z", + }, + { + id: "tool-result-patch", + roundId: "round-patch", + role: "user", + content: [{ + type: "tool_result", + toolUseId: "call-patch", + content: [{ + type: "text", + text: "{\"operations\":[{\"type\":\"update_file\",\"path\":\"notes.txt\"}]}", + }], + isError: false, + }], + createdAt: "2026-01-01T00:00:02Z", + }, + ], + usage: { input: 10, output: 5, total: 15 }, + startedAt: "2026-01-01T00:00:00Z", + endedAt: "2026-01-01T00:00:03Z", + }], + }; + const client = { + async getSession() { + return persisted; + }, + async resolveModel() { + return { + code: "model", + providerCode: "provider", + providerName: "Provider", + name: "Model", + contextWindow: 32_000, + maxOutputTokens: 8192, + multiModal: false, + light: false, + isDefault: true, + }; + }, + } as unknown as AgentyClient; + + useAppStore.setState({ client, session, history: [], current: null }); + await useAppStore.getState().resumeSession(session); + + const call = useAppStore.getState().history[0]?.toolCalls?.[0]; + expect(call).toMatchObject({ id: "call-patch", name: "apply_patch" }); + expect(JSON.parse(call?.arguments ?? "{}")).toEqual({ + operation: { + type: "update_file", + path: "notes.txt", + diff: "@@\n-old\n+new", + }, + }); + expect(call?.result).toMatchObject({ + callId: "call-patch", + name: "apply_patch", + isError: false, + }); + }); }); diff --git a/packages/agenty-cli/src/state/store.ts b/packages/agenty-cli/src/state/store.ts index 263e095..beb23d7 100644 --- a/packages/agenty-cli/src/state/store.ts +++ b/packages/agenty-cli/src/state/store.ts @@ -139,6 +139,14 @@ function toolCallsFromBlocks(blocks: ContentBlock[]): UIToolCall[] { name: "shell", arguments: JSON.stringify(input), }); + } else if (block.type === "apply_patch_call") { + calls.push({ + id: block.callId, + name: "apply_patch", + arguments: JSON.stringify(block.source === "native" + ? { operation: block.operation } + : { patch: block.patch }), + }); } } return calls; @@ -634,7 +642,7 @@ export const useAppStore = create((set, get) => { return; } try { - const session = await client.createSession(agent.slug, model, reasoningEffort(thinkingEnabled, thinkingLevel)); + const session = await client.createSession(agent.code, model, reasoningEffort(thinkingEnabled, thinkingLevel)); set({ session, history: [], current: null, tokenConsumed: 0, overlay: null }); setToast("New session created."); } catch (error) { @@ -658,7 +666,7 @@ export const useAppStore = create((set, get) => { phrase: null, activeSessionId: null, }); - setToast(`Switched to ${model.providerName}/${model.name}`); + setToast(`Switched to ${model.providerName} · ${model.name}`); } catch (error) { set({ status: "idle", phrase: null, activeSessionId: null }); pushSystem(`switch model failed: ${(error as Error).message}`, true); @@ -673,7 +681,7 @@ export const useAppStore = create((set, get) => { try { const full = await client.getSession(session.id); const model = full.currentModel - ? await client.resolveModel(`${full.currentModel.providerSlug}/${full.currentModel.modelSlug}`) + ? await client.resolveModel(`${full.currentModel.providerCode}/${full.currentModel.modelCode}`) : get().model; set({ session: full, model, history: buildHistory(full), current: null, tokenConsumed: actualContextSize(full), overlay: null }); } catch (error) { @@ -688,9 +696,9 @@ export const useAppStore = create((set, get) => { } try { const model = agent.defaultModel - ? await client.resolveModel(`${agent.defaultModel.providerSlug}/${agent.defaultModel.modelSlug}`) + ? await client.resolveModel(`${agent.defaultModel.providerCode}/${agent.defaultModel.modelCode}`) : await client.getDefaultModel(); - const session = await client.getLastSessionByAgent(agent.slug) ?? await client.createSession(agent.slug, model); + const session = await client.getLastSessionByAgent(agent.code) ?? await client.createSession(agent.code, model); set({ agent, model, session, history: buildHistory(session), current: null, tokenConsumed: actualContextSize(session), overlay: null }); setToast(`Switched to agent: ${agent.name}`); } catch (error) { diff --git a/packages/agenty-core/README-CN.md b/packages/agenty-core/README-CN.md index 3834123..a28bde0 100644 --- a/packages/agenty-core/README-CN.md +++ b/packages/agenty-core/README-CN.md @@ -14,9 +14,8 @@ Agenty 的核心运行时。它围绕本地优先的存储模型(文件系统 | Session transcript | `~/.agenty/sessions///
/.jsonl` | 写模型,即 append-only event log(真实数据源) | | Session index | `~/.agenty/agenty.sqlite` -> `sessions` | 读模型,用于快速列表和搜索的投影 | | 全局配置 | `~/.agenty/config.json` | 应用配置 | -| Providers | `~/.agenty/providers//provider.json` | Catalog aggregate | -| Models | `~/.agenty/providers//models/.json` | Catalog aggregate member | -| Agents | `~/.agenty/agents/.json` | Agent aggregate | +| Providers | `~/.agenty/providers/.json` | Catalog aggregate,包含其模型 | +| Agents | `~/.agenty/agents/.json` | Agent aggregate | | Core 日志 | `~/.agenty/logs///
/core.log` | 结构化文本诊断信息(JSONL 模式下为 `core.jsonl`) | Session 的 messages 和 rounds 永远不会存入 SQLite;`sessions` 表是摘要投影,可以通过 @@ -26,11 +25,11 @@ reasoning effort。 ## 领域层 领域层按 bounded context 拆分。Aggregates 之间只通过 identity 相互引用(conversation -系列使用 UUIDv7,agents、providers 和 models 使用 kebab-case slugs)。 +系列使用 UUIDv7,agents 和 providers 使用路径安全的 code,models 使用可保留上游字符的 code)。 ``` pkg/domain/ -├── shared/ Shared kernel: Slug, ModelRef, ReasoningEffort, Metadata, Event, ID +├── shared/ Shared kernel: Code, ModelRef, ReasoningEffort, Metadata, Event, ID ├── conversation/ Session aggregate (Session -> Round -> Message), content blocks, events ├── agent/ Agent aggregate └── catalog/ Provider aggregate (Provider -> Model) @@ -103,7 +102,7 @@ pkg/infra/ ├── storage/ Repository 实现 + SQLite connection factory │ ├── db.go OpenDB/OpenIsolatedDB + sessions schema │ ├── agent.go AgentRepository(agent JSON 文件) -│ ├── catalog.go CatalogRepository(provider/model JSON 文件、DeleteModel) +│ ├── catalog.go CatalogRepository(provider 聚合 JSON,内嵌 models) │ └── conversation.go ConversationRepository(JSONL transcript + SQLite projection) └── rpc/ stdio JSON-RPC 2.0 接口层 ├── message.go Request/Response/Notification/Error/ID wire types @@ -223,8 +222,8 @@ chunk payload too large。Application validation errors 映射为 `-32602`。 示例: ``` -$ echo '{"jsonrpc":"2.0","id":1,"method":"agent.create","params":{"slug":"dev","name":"Dev"}}' | go run ./cmd -{"jsonrpc":"2.0","id":1,"result":{"slug":"dev","name":"Dev",...}} +$ echo '{"jsonrpc":"2.0","id":1,"method":"agent.create","params":{"code":"dev","name":"Dev"}}' | go run ./cmd +{"jsonrpc":"2.0","id":1,"result":{"code":"dev","name":"Dev",...}} ``` 说明:`rpc` 和 `adapter` packages 使用 `encoding/json`(原生支持 RawMessage、无依赖), diff --git a/packages/agenty-core/README.md b/packages/agenty-core/README.md index 88313bd..26b5716 100644 --- a/packages/agenty-core/README.md +++ b/packages/agenty-core/README.md @@ -14,9 +14,8 @@ The filesystem is the source of truth; SQLite is a query-side projection. | Session transcript | `~/.agenty/sessions///
/.jsonl` | Write model — append-only event log (source of truth) | | Session index | `~/.agenty/agenty.sqlite` → `sessions` | Read model — projection for fast listing/search | | Global config | `~/.agenty/config.json` | Application configuration | -| Providers | `~/.agenty/providers//provider.json` | Catalog aggregate | -| Models | `~/.agenty/providers//models/.json` | Catalog aggregate member | -| Agents | `~/.agenty/agents/.json` | Agent aggregate | +| Providers | `~/.agenty/providers/.json` | Catalog aggregate, including its models | +| Agents | `~/.agenty/agents/.json` | Agent aggregate | | Core log | `~/.agenty/logs///
/core.log` | Structured text diagnostics (`core.jsonl` in JSONL mode) | A session's messages and rounds are never stored in SQLite; the `sessions` table is a @@ -27,12 +26,12 @@ reasoning effort. ## Domain layer The domain layer is split by bounded context. Aggregates reference each other only by -identity (UUIDv7 for the conversation family, kebab-case slugs for agents, providers, -and models). +identity (UUIDv7 for the conversation family, path-safe codes for agents and providers, +and opaque upstream codes for models). ``` pkg/domain/ -├── shared/ Shared kernel: Slug, ModelRef, ReasoningEffort, Metadata, Event, ID +├── shared/ Shared kernel: Code, ModelRef, ReasoningEffort, Metadata, Event, ID ├── conversation/ Session aggregate (Session → Round → Message), content blocks, events ├── agent/ Agent aggregate └── catalog/ Provider aggregate (Provider → Model) @@ -111,7 +110,7 @@ pkg/infra/ ├── storage/ Repository implementations + SQLite connection factory │ ├── db.go OpenDB/OpenIsolatedDB + sessions schema │ ├── agent.go AgentRepository (agent JSON files) -│ ├── catalog.go CatalogRepository (provider/model JSON files, DeleteModel) +│ ├── catalog.go CatalogRepository (provider aggregate JSON, embedded models) │ └── conversation.go ConversationRepository (JSONL transcript + SQLite projection) └── rpc/ stdio JSON-RPC 2.0 interface layer ├── message.go Request/Response/Notification/Error/ID wire types @@ -251,8 +250,8 @@ map to `-32602`. Example: ``` -$ echo '{"jsonrpc":"2.0","id":1,"method":"agent.create","params":{"slug":"dev","name":"Dev"}}' | go run ./cmd -{"jsonrpc":"2.0","id":1,"result":{"slug":"dev","name":"Dev",...}} +$ echo '{"jsonrpc":"2.0","id":1,"method":"agent.create","params":{"code":"dev","name":"Dev"}}' | go run ./cmd +{"jsonrpc":"2.0","id":1,"result":{"code":"dev","name":"Dev",...}} ``` Note: the `rpc` and `adapter` packages use `encoding/json` (RawMessage-native, diff --git a/packages/agenty-core/TESTING-CN.md b/packages/agenty-core/TESTING-CN.md index a1529a5..71deff9 100644 --- a/packages/agenty-core/TESTING-CN.md +++ b/packages/agenty-core/TESTING-CN.md @@ -7,7 +7,7 @@ | 范围 | 测试环境 | 覆盖行为 | 默认运行 | | --- | --- | --- | --- | -| Domain | 仅内存值 | 聚合不变量、Session 状态转换与 replay、event 和 content 序列化、Provider model 生命周期、slug 和 reasoning effort 映射校验 | 是 | +| Domain | 仅内存值 | 聚合不变量、Session 状态转换与 replay、event 和 content 序列化、Provider model 生命周期、code 和 reasoning effort 映射校验 | 是 | | Application | 内存 repository fake | Agent、Provider 和 Session 用例;execution loop 完成、tool continuation、model 输出 token 上限、多 session 并行、取消、shutdown、输入校验、错误映射和 pending event 生命周期 | 是 | | 内置工具 | `t.TempDir()` 和真实文件系统操作 | 注册、相对路径解析、范围读取、创建/覆盖、精确 patch、单文件删除、正则搜索、递归 glob、目录列表、输出限制和错误路径 | 是 | | RPC | buffer、fake handler 和合成时间 | JSON-RPC/NDJSON framing、notification、batch、非法请求、单行限制、chunk 组装与清理 | 是 | diff --git a/packages/agenty-core/TESTING.md b/packages/agenty-core/TESTING.md index b1a542f..c8aa44e 100644 --- a/packages/agenty-core/TESTING.md +++ b/packages/agenty-core/TESTING.md @@ -7,7 +7,7 @@ Chinese version, see [TESTING-CN.md](./TESTING-CN.md). | Area | Environment | Covered behavior | Default suite | | --- | --- | --- | --- | -| Domain | In-memory values | Aggregate invariants, Session transitions and replay, event and content serialization, Provider model lifecycle, slug and reasoning effort mapping validation | Yes | +| Domain | In-memory values | Aggregate invariants, Session transitions and replay, event and content serialization, Provider model lifecycle, code and reasoning effort mapping validation | Yes | | Application | In-memory repository fakes | Agent, Provider, and Session use cases; execution-loop completion, tool continuation, per-model token limits, multi-session concurrency, cancellation, shutdown, validation, error mapping, and pending-event lifecycle | Yes | | Built-in tools | `t.TempDir()` and real filesystem operations | Registration, relative path resolution, ranged reads, create/overwrite, exact patching, safe single-file deletion, regular-expression search, recursive globbing, directory listing, output limits, and error paths | Yes | | RPC | Buffers, fake handlers, and synthetic time | JSON-RPC/NDJSON framing, notifications, batches, invalid requests, line limits, chunk assembly, and cleanup | Yes | diff --git a/packages/agenty-core/pkg/agentloop/builtin/apply_patch.go b/packages/agenty-core/pkg/agentloop/builtin/apply_patch.go new file mode 100644 index 0000000..ea13a14 --- /dev/null +++ b/packages/agenty-core/pkg/agentloop/builtin/apply_patch.go @@ -0,0 +1,307 @@ +package builtin + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/masteryyh/agenty-core/pkg/agentloop" + "github.com/masteryyh/agenty-core/pkg/domain/conversation" + "github.com/masteryyh/agenty-core/pkg/utils" +) + +const ( + patchBeginMarker = "*** Begin Patch" + patchEndMarker = "*** End Patch" + patchUpdateFile = "*** Update File:" + patchDeleteFile = "*** Delete File:" + patchAddFile = "*** Add File:" + patchMoveTo = "*** Move to:" +) + +type applyPatchTool struct { + fileSystem *fileSystem +} + +type applyPatchArguments struct { + Operation *conversation.ApplyPatchOperation `json:"operation,omitempty"` + Patch string `json:"patch,omitempty"` +} + +type applyPatchOperationResult struct { + Type conversation.ApplyPatchOperationType `json:"type"` + Path string `json:"path"` + MoveTo string `json:"moveTo,omitempty"` +} + +type applyPatchResult struct { + Operations []applyPatchOperationResult `json:"operations"` +} + +func (tool *applyPatchTool) Definition() agentloop.ToolDefinition { + operationSchema := objectSchema( + map[string]agentloop.JSONSchema{ + "type": stringSchema("Operation type: create_file, update_file, or delete_file."), + "path": stringSchema("Absolute path or path relative to the session working directory."), + "diff": stringSchema("Headerless V4A diff body for create_file or update_file."), + }, + []string{"type", "path"}, + ) + return agentloop.ToolDefinition{ + Type: agentloop.ToolTypeApplyPatch, + Name: "apply_patch", + Description: "Apply one native file operation or a complete V4A patch envelope.", + InputSchema: objectSchema( + map[string]agentloop.JSONSchema{ + "operation": operationSchema, + "patch": stringSchema("Complete V4A patch envelope."), + }, + nil, + ), + } +} + +func (tool *applyPatchTool) Execute( + ctx context.Context, + callContext agentloop.CallContext, + input []byte, +) (conversation.Content, error) { + var arguments applyPatchArguments + if err := decodeArguments(input, &arguments); err != nil { + return nil, fmt.Errorf("apply_patch: %w", err) + } + + hasOperation := arguments.Operation != nil + hasPatch := arguments.Patch != "" + if hasOperation == hasPatch { + return nil, fmt.Errorf("apply_patch: exactly one of operation or patch is required") + } + + operations := make([]conversation.ApplyPatchOperation, 0, 1) + if hasOperation { + operations = append(operations, *arguments.Operation) + } else { + parsed, err := parsePatchEnvelope(arguments.Patch) + if err != nil { + return nil, fmt.Errorf("apply_patch: parse patch: %w", err) + } + operations = parsed + } + + tool.fileSystem.mu.Lock() + defer tool.fileSystem.mu.Unlock() + + results := make([]applyPatchOperationResult, 0, len(operations)) + for index, operation := range operations { + if err := ctx.Err(); err != nil { + return nil, err + } + result, err := executeApplyPatchOperation(callContext.Cwd, operation) + if err != nil { + return nil, fmt.Errorf( + "apply_patch: operation %d %s %q: %w", + index+1, + operation.Type, + operation.Path, + err, + ) + } + results = append(results, result) + } + + return resultContent(applyPatchResult{Operations: results}) +} + +func parsePatchEnvelope(patch string) ([]conversation.ApplyPatchOperation, error) { + lines := normalizePatchEnvelopeLines(patch) + if len(lines) < 3 || lines[0] != patchBeginMarker { + return nil, fmt.Errorf("patch must start with %q", patchBeginMarker) + } + if lines[len(lines)-1] != patchEndMarker { + return nil, fmt.Errorf("patch must end with %q", patchEndMarker) + } + + operations := make([]conversation.ApplyPatchOperation, 0) + for index := 1; index < len(lines)-1; { + operation, nextIndex, err := parsePatchEnvelopeOperation(lines, index) + if err != nil { + return nil, err + } + operations = append(operations, operation) + index = nextIndex + } + if len(operations) == 0 { + return nil, fmt.Errorf("patch contains no file operations") + } + return operations, nil +} + +func normalizePatchEnvelopeLines(patch string) []string { + lines := strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") + for index := range lines { + lines[index] = strings.TrimSuffix(lines[index], "\r") + } + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func parsePatchEnvelopeOperation( + lines []string, + index int, +) (conversation.ApplyPatchOperation, int, error) { + header := lines[index] + operation := conversation.ApplyPatchOperation{} + switch { + case strings.HasPrefix(header, patchUpdateFile): + operation.Type = conversation.ApplyPatchUpdateFile + operation.Path = strings.TrimSpace(strings.TrimPrefix(header, patchUpdateFile)) + case strings.HasPrefix(header, patchDeleteFile): + operation.Type = conversation.ApplyPatchDeleteFile + operation.Path = strings.TrimSpace(strings.TrimPrefix(header, patchDeleteFile)) + case strings.HasPrefix(header, patchAddFile): + operation.Type = conversation.ApplyPatchCreateFile + operation.Path = strings.TrimSpace(strings.TrimPrefix(header, patchAddFile)) + default: + return conversation.ApplyPatchOperation{}, 0, fmt.Errorf( + "invalid patch header at line %d: %s", + index+1, + header, + ) + } + if operation.Path == "" { + return conversation.ApplyPatchOperation{}, 0, fmt.Errorf("operation at line %d has an empty path", index+1) + } + + index++ + if operation.Type == conversation.ApplyPatchUpdateFile && index < len(lines)-1 && + strings.HasPrefix(lines[index], patchMoveTo) { + operation.MoveTo = strings.TrimSpace(strings.TrimPrefix(lines[index], patchMoveTo)) + if operation.MoveTo == "" { + return conversation.ApplyPatchOperation{}, 0, fmt.Errorf("move at line %d has an empty path", index+1) + } + index++ + } + + bodyStart := index + for index < len(lines)-1 && !isPatchOperationHeader(lines[index]) { + index++ + } + body := lines[bodyStart:index] + if operation.Type == conversation.ApplyPatchDeleteFile && len(body) > 0 { + return conversation.ApplyPatchOperation{}, 0, fmt.Errorf( + "delete operation for %q must not contain a diff body", + operation.Path, + ) + } + operation.Diff = strings.Join(body, "\n") + return operation, index, nil +} + +func isPatchOperationHeader(line string) bool { + return strings.HasPrefix(line, patchUpdateFile) || + strings.HasPrefix(line, patchDeleteFile) || + strings.HasPrefix(line, patchAddFile) +} + +func executeApplyPatchOperation( + cwd string, + operation conversation.ApplyPatchOperation, +) (applyPatchOperationResult, error) { + path, err := resolvePath(operation.Path, cwd, false) + if err != nil { + return applyPatchOperationResult{}, err + } + result := applyPatchOperationResult{Type: operation.Type, Path: path} + + switch operation.Type { + case conversation.ApplyPatchCreateFile: + content, err := utils.ApplyDiff("", operation.Diff, utils.ApplyDiffCreate) + if err != nil { + return applyPatchOperationResult{}, fmt.Errorf("apply create diff: %w", err) + } + if _, err := writeTextFile(path, content, 0o644); err != nil { + return applyPatchOperationResult{}, err + } + case conversation.ApplyPatchUpdateFile: + if err := updateFileWithDiff(path, operation.Diff); err != nil { + return applyPatchOperationResult{}, err + } + if operation.MoveTo != "" { + moveTo, err := resolvePath(operation.MoveTo, cwd, false) + if err != nil { + return applyPatchOperationResult{}, fmt.Errorf("resolve move destination: %w", err) + } + if err := movePatchedFile(path, moveTo); err != nil { + return applyPatchOperationResult{}, err + } + result.MoveTo = moveTo + } + case conversation.ApplyPatchDeleteFile: + if err := removeApplyPatchFile(path); err != nil { + return applyPatchOperationResult{}, err + } + default: + return applyPatchOperationResult{}, fmt.Errorf("unsupported operation type %q", operation.Type) + } + + return result, nil +} + +func updateFileWithDiff(path, diff string) error { + info, err := regularFileInfo(path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %q: %w", path, err) + } + updated, err := utils.ApplyDiff(string(data), diff, utils.ApplyDiffDefault) + if err != nil { + return fmt.Errorf("apply update diff: %w", err) + } + if _, err := writeTextFile(path, updated, info.Mode().Perm()); err != nil { + return err + } + return nil +} + +func movePatchedFile(source, destination string) error { + if source == destination { + return nil + } + if _, err := os.Lstat(destination); err == nil { + return fmt.Errorf("move destination %q already exists", destination) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect move destination %q: %w", destination, err) + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("create move destination parent for %q: %w", destination, err) + } + if err := os.Rename(source, destination); err != nil { + return fmt.Errorf("move %q to %q: %w", source, destination, err) + } + return nil +} + +func removeApplyPatchFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect %q: %w", path, err) + } + if info.IsDir() { + return fmt.Errorf("path %q is a directory", path) + } + if !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("path %q is not a file or symbolic link", path) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove %q: %w", path, err) + } + return nil +} diff --git a/packages/agenty-core/pkg/agentloop/builtin/apply_patch_test.go b/packages/agenty-core/pkg/agentloop/builtin/apply_patch_test.go new file mode 100644 index 0000000..3f704e2 --- /dev/null +++ b/packages/agenty-core/pkg/agentloop/builtin/apply_patch_test.go @@ -0,0 +1,190 @@ +package builtin + +import ( + "os" + "path/filepath" + "strings" + "testing" + + json "github.com/bytedance/sonic" + + "github.com/masteryyh/agenty-core/pkg/agentloop" + "github.com/masteryyh/agenty-core/pkg/domain/conversation" +) + +func TestParsePatchEnvelopePreservesRepeatedFileOperations(t *testing.T) { + t.Parallel() + + patch := `*** Begin Patch +*** Update File: notes.txt +@@ +-one ++two +*** Update File: notes.txt +@@ +-two ++three +*** End Patch` + operations, err := parsePatchEnvelope(patch) + if err != nil { + t.Fatal(err) + } + if len(operations) != 2 { + t.Fatalf("operations = %d, want 2", len(operations)) + } + for index, operation := range operations { + if operation.Path != "notes.txt" { + t.Errorf("operation %d path = %q, want notes.txt", index, operation.Path) + } + } + if operations[0].Diff == operations[1].Diff { + t.Errorf("repeated operations were collapsed: %#v", operations) + } +} + +func TestApplyPatchToolExecutesOperationsInOrder(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + tool := &applyPatchTool{fileSystem: &fileSystem{}} + patch := `*** Begin Patch +*** Add File: notes.txt ++one +*** Update File: notes.txt +@@ +-one ++two +*** Update File: notes.txt +@@ +-two ++three +*** End Patch` + content, err := executeApplyPatchTool(t, tool, directory, applyPatchArguments{Patch: patch}) + if err != nil { + t.Fatal(err) + } + if len(content) != 1 { + t.Fatalf("content = %d blocks, want 1", len(content)) + } + data, err := os.ReadFile(filepath.Join(directory, "notes.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "three" { + t.Errorf("notes.txt = %q, want three", data) + } +} + +func TestApplyPatchToolSupportsMoveThenUpdateDestination(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, "old.txt"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + tool := &applyPatchTool{fileSystem: &fileSystem{}} + patch := `*** Begin Patch +*** Update File: old.txt +*** Move to: new.txt +@@ +-one ++two +*** Update File: new.txt +@@ +-two ++three +*** End Patch` + if _, err := executeApplyPatchTool(t, tool, directory, applyPatchArguments{Patch: patch}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(directory, "old.txt")); !os.IsNotExist(err) { + t.Fatalf("old.txt still exists: %v", err) + } + data, err := os.ReadFile(filepath.Join(directory, "new.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "three\n" { + t.Errorf("new.txt = %q, want %q", data, "three\n") + } +} + +func TestApplyPatchToolKeepsEarlierOperationsOnFailure(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + tool := &applyPatchTool{fileSystem: &fileSystem{}} + patch := `*** Begin Patch +*** Add File: created.txt ++created +*** Update File: missing.txt +@@ +-missing ++updated +*** End Patch` + _, err := executeApplyPatchTool(t, tool, directory, applyPatchArguments{Patch: patch}) + if err == nil || !strings.Contains(err.Error(), "operation 2") { + t.Fatalf("Execute() error = %v, want operation 2 failure", err) + } + data, readErr := os.ReadFile(filepath.Join(directory, "created.txt")) + if readErr != nil { + t.Fatal(readErr) + } + if string(data) != "created" { + t.Errorf("created.txt = %q, want created", data) + } +} + +func TestApplyPatchToolExecutesNativeOperation(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + tool := &applyPatchTool{fileSystem: &fileSystem{}} + operation := conversation.ApplyPatchOperation{ + Type: conversation.ApplyPatchCreateFile, + Path: "native.txt", + Diff: "+native", + } + if _, err := executeApplyPatchTool(t, tool, directory, applyPatchArguments{Operation: &operation}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(directory, "native.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "native" { + t.Errorf("native.txt = %q, want native", data) + } +} + +func TestApplyPatchToolRejectsMalformedEnvelopeBeforeWriting(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + tool := &applyPatchTool{fileSystem: &fileSystem{}} + patch := `*** Begin Patch +*** Add File: created.txt ++created` + _, err := executeApplyPatchTool(t, tool, directory, applyPatchArguments{Patch: patch}) + if err == nil || !strings.Contains(err.Error(), "must end") { + t.Fatalf("Execute() error = %v, want missing end marker", err) + } + if _, statErr := os.Stat(filepath.Join(directory, "created.txt")); !os.IsNotExist(statErr) { + t.Fatalf("created.txt exists after parse failure: %v", statErr) + } +} + +func executeApplyPatchTool( + t *testing.T, + tool *applyPatchTool, + cwd string, + arguments applyPatchArguments, +) (conversation.Content, error) { + t.Helper() + + input, err := json.Marshal(arguments) + if err != nil { + t.Fatal(err) + } + return tool.Execute(t.Context(), agentloop.CallContext{Cwd: cwd}, input) +} diff --git a/packages/agenty-core/pkg/agentloop/builtin/register.go b/packages/agenty-core/pkg/agentloop/builtin/register.go index 71f4b7f..39460de 100644 --- a/packages/agenty-core/pkg/agentloop/builtin/register.go +++ b/packages/agenty-core/pkg/agentloop/builtin/register.go @@ -18,6 +18,7 @@ func RegisterAll(registry *agentloop.Registry) error { &writeFileTool{fileSystem: fileSystem}, &patchFileTool{fileSystem: fileSystem}, &deleteFileTool{fileSystem: fileSystem}, + &applyPatchTool{fileSystem: fileSystem}, &grepTool{fileSystem: fileSystem}, &globTool{fileSystem: fileSystem}, &listTool{fileSystem: fileSystem}, diff --git a/packages/agenty-core/pkg/agentloop/builtin/register_test.go b/packages/agenty-core/pkg/agentloop/builtin/register_test.go index 1383754..a813674 100644 --- a/packages/agenty-core/pkg/agentloop/builtin/register_test.go +++ b/packages/agenty-core/pkg/agentloop/builtin/register_test.go @@ -22,6 +22,7 @@ func TestRegisterAll(t *testing.T) { } wantNames := []string{ + "apply_patch", "delete_file", "glob", "grep", diff --git a/packages/agenty-core/pkg/agentloop/engine.go b/packages/agenty-core/pkg/agentloop/engine.go index 9ce57f4..959a961 100644 --- a/packages/agenty-core/pkg/agentloop/engine.go +++ b/packages/agenty-core/pkg/agentloop/engine.go @@ -28,11 +28,11 @@ type ExecutionSessionRepository interface { } type ExecutionAgentRepository interface { - Get(ctx context.Context, slug shared.Slug) (*agent.Agent, error) + Get(ctx context.Context, code shared.Code) (*agent.Agent, error) } type ExecutionCatalogRepository interface { - Get(ctx context.Context, slug shared.Slug) (*catalog.Provider, error) + Get(ctx context.Context, code shared.Code) (*catalog.Provider, error) } type CallerFactory func( @@ -251,18 +251,18 @@ func (engine *Engine) Compact( func (engine *Engine) SetModel( ctx context.Context, sessionID string, - providerSlug string, - modelSlug string, + providerCode string, + modelCode string, ) (*conversation.Session, error) { id, err := uuid.Parse(sessionID) if err != nil { return nil, apperrors.Validation("invalid session id: " + err.Error()) } - providerID, err := shared.NewSlug(providerSlug) + providerCodeValue, err := shared.NewCode(providerCode) if err != nil { return nil, apperrors.Validation(err.Error()) } - modelID, err := shared.NewModelID(modelSlug) + modelCodeValue, err := shared.NewModelCode(modelCode) if err != nil { return nil, apperrors.Validation(err.Error()) } @@ -281,8 +281,8 @@ func (engine *Engine) SetModel( return nil, apperrors.WrapError(apperrors.CodeInternal, "failed to load session", err) } if session.CurrentModel != nil && - session.CurrentModel.ProviderSlug == providerID && - session.CurrentModel.ModelSlug == modelID { + session.CurrentModel.ProviderCode == providerCodeValue && + session.CurrentModel.ModelCode == modelCodeValue { return session.VisibleCopy(), nil } @@ -290,7 +290,7 @@ func (engine *Engine) SetModel( if err != nil { return nil, err } - targetRef := shared.NewModelRef(providerID, modelID) + targetRef := shared.NewModelRef(providerCodeValue, modelCodeValue) _, targetModel, err := engine.loadCatalogModel(ctx, targetRef) if err != nil { return nil, err @@ -479,10 +479,10 @@ func (engine *Engine) loadResources( runCtx context.Context, session *conversation.Session, ) (*executionResources, error) { - agentDefinition, err := engine.agents.Get(ctx, session.AgentSlug) + agentDefinition, err := engine.agents.Get(ctx, session.AgentCode) if err != nil { if errors.Is(err, agent.ErrNotFound) { - return nil, apperrors.NotFound("agent " + session.AgentSlug.String() + " not found") + return nil, apperrors.NotFound("agent " + session.AgentCode.String() + " not found") } return nil, apperrors.WrapError(apperrors.CodeInternal, "failed to load agent", err) } @@ -510,17 +510,17 @@ func (engine *Engine) loadCatalogModel( ctx context.Context, modelRef shared.ModelRef, ) (*catalog.Provider, *catalog.Model, error) { - provider, err := engine.catalog.Get(ctx, modelRef.ProviderSlug) + provider, err := engine.catalog.Get(ctx, modelRef.ProviderCode) if err != nil { if errors.Is(err, catalog.ErrProviderNotFound) { - return nil, nil, apperrors.NotFound("provider " + modelRef.ProviderSlug.String() + " not found") + return nil, nil, apperrors.NotFound("provider " + modelRef.ProviderCode.String() + " not found") } return nil, nil, apperrors.WrapError(apperrors.CodeInternal, "failed to load provider", err) } - model, err := provider.Model(modelRef.ModelSlug) + model, err := provider.Model(modelRef.ModelCode) if err != nil { if errors.Is(err, catalog.ErrModelNotFound) { - return nil, nil, apperrors.NotFound("model " + modelRef.ModelSlug.String() + " not found") + return nil, nil, apperrors.NotFound("model " + modelRef.ModelCode.String() + " not found") } return nil, nil, apperrors.WrapError(apperrors.CodeInternal, "failed to load model", err) } @@ -812,6 +812,8 @@ func toolCalls(content conversation.Content) []conversation.ToolUseBlock { calls = append(calls, call) case conversation.ShellCallBlock: calls = append(calls, call.ToolUseBlock()) + case conversation.ApplyPatchCallBlock: + calls = append(calls, call.ToolUseBlock()) } } diff --git a/packages/agenty-core/pkg/agentloop/engine_test.go b/packages/agenty-core/pkg/agentloop/engine_test.go index 006f94b..cf801e1 100644 --- a/packages/agenty-core/pkg/agentloop/engine_test.go +++ b/packages/agenty-core/pkg/agentloop/engine_test.go @@ -134,7 +134,7 @@ func newExecutionFixture(t *testing.T, maxOutputTokens int64) *executionFixture } provider.APIKey = "test-key" provider.AddModel(catalog.Model{ - Slug: "gpt-5", + Code: "gpt-5", Name: "GPT-5", ContextWindow: 128_000, MaxOutputTokens: maxOutputTokens, @@ -616,7 +616,7 @@ func TestModelSwitchCompactsWithCurrentModelBeforePersistingTarget(t *testing.T) t.Fatal(err) } provider.AddModel(catalog.Model{ - Slug: "small-model", + Code: "small-model", Name: "Small Model", ContextWindow: 4_000, }) @@ -629,8 +629,8 @@ func TestModelSwitchCompactsWithCurrentModelBeforePersistingTarget(t *testing.T) StopReason: agentloop.StopReasonEndTurn, }}} engine := fixture.newEngine(t, func(_ context.Context, _ catalog.Provider, model catalog.Model) (agentloop.Caller, error) { - if model.Slug != "gpt-5" { - t.Fatalf("compression used target model %q", model.Slug) + if model.Code != "gpt-5" { + t.Fatalf("compression used target model %q", model.Code) } return caller, nil }) @@ -651,7 +651,7 @@ func TestModelSwitchCompactsWithCurrentModelBeforePersistingTarget(t *testing.T) if err != nil { t.Fatal(err) } - if updated.CurrentModel == nil || updated.CurrentModel.ModelSlug != "small-model" || updated.ContextWindow != 4_000 { + if updated.CurrentModel == nil || updated.CurrentModel.ModelCode != "small-model" || updated.ContextWindow != 4_000 { t.Fatalf("updated session model = %+v, context window = %d", updated.CurrentModel, updated.ContextWindow) } if len(caller.Requests()) != 1 { diff --git a/packages/agenty-core/pkg/agentloop/metadata.go b/packages/agenty-core/pkg/agentloop/metadata.go index cb972fa..0d60671 100644 --- a/packages/agenty-core/pkg/agentloop/metadata.go +++ b/packages/agenty-core/pkg/agentloop/metadata.go @@ -26,8 +26,8 @@ func fullMetadataXML(session *conversation.Session) (string, error) { metadata := conversation.SessionMetadata{ Cwd: cwd, - Model: session.CurrentModel.ModelSlug.String(), - Provider: session.CurrentModel.ProviderSlug.String(), + Model: session.CurrentModel.ModelCode.String(), + Provider: session.CurrentModel.ProviderCode.String(), Timezone: utils.TimezoneName(), ReasoningEffort: string(session.CurrentReasoningEffort), } @@ -45,8 +45,8 @@ func metadataForRound( current := conversation.SessionMetadata{ Cwd: cwd, - Model: round.Model.ModelSlug.String(), - Provider: round.Model.ProviderSlug.String(), + Model: round.Model.ModelCode.String(), + Provider: round.Model.ProviderCode.String(), Timezone: utils.TimezoneName(), ReasoningEffort: string(round.ReasoningEffort), } diff --git a/packages/agenty-core/pkg/agentloop/testhelper_test.go b/packages/agenty-core/pkg/agentloop/testhelper_test.go index 0351f36..b108755 100644 --- a/packages/agenty-core/pkg/agentloop/testhelper_test.go +++ b/packages/agenty-core/pkg/agentloop/testhelper_test.go @@ -18,18 +18,18 @@ import ( ) type agentRepositoryFake struct { - agents map[shared.Slug]*agent.Agent + agents map[shared.Code]*agent.Agent } func newAgentRepositoryFake() *agentRepositoryFake { - return &agentRepositoryFake{agents: make(map[shared.Slug]*agent.Agent)} + return &agentRepositoryFake{agents: make(map[shared.Code]*agent.Agent)} } func (repository *agentRepositoryFake) Get( _ context.Context, - slug shared.Slug, + code shared.Code, ) (*agent.Agent, error) { - definition, ok := repository.agents[slug] + definition, ok := repository.agents[code] if !ok { return nil, storage.ErrAgentNotFound } @@ -42,23 +42,23 @@ func (repository *agentRepositoryFake) Get( func (repository *agentRepositoryFake) Save(_ context.Context, definition *agent.Agent) error { copy := *definition copy.Metadata = maps.Clone(definition.Metadata) - repository.agents[definition.Slug] = © + repository.agents[definition.Code] = © return nil } type providerRepositoryFake struct { - providers map[shared.Slug]*catalog.Provider + providers map[shared.Code]*catalog.Provider } func newProviderRepositoryFake() *providerRepositoryFake { - return &providerRepositoryFake{providers: make(map[shared.Slug]*catalog.Provider)} + return &providerRepositoryFake{providers: make(map[shared.Code]*catalog.Provider)} } func (repository *providerRepositoryFake) Get( _ context.Context, - slug shared.Slug, + code shared.Code, ) (*catalog.Provider, error) { - provider, ok := repository.providers[slug] + provider, ok := repository.providers[code] if !ok { return nil, storage.ErrProviderNotFound } @@ -67,7 +67,7 @@ func (repository *providerRepositoryFake) Get( } func (repository *providerRepositoryFake) Save(_ context.Context, provider *catalog.Provider) error { - repository.providers[provider.Slug] = cloneProvider(provider) + repository.providers[provider.Code] = cloneProvider(provider) return nil } diff --git a/packages/agenty-core/pkg/agentloop/types.go b/packages/agenty-core/pkg/agentloop/types.go index eb7a918..42bc7cf 100644 --- a/packages/agenty-core/pkg/agentloop/types.go +++ b/packages/agenty-core/pkg/agentloop/types.go @@ -33,8 +33,9 @@ const ( type ToolType string const ( - ToolTypeFunction ToolType = "function" - ToolTypeShell ToolType = "shell" + ToolTypeFunction ToolType = "function" + ToolTypeShell ToolType = "shell" + ToolTypeApplyPatch ToolType = "apply_patch" ) type ToolDefinition struct { diff --git a/packages/agenty-core/pkg/application/agent.go b/packages/agenty-core/pkg/application/agent.go index 67915f7..de4647f 100644 --- a/packages/agenty-core/pkg/application/agent.go +++ b/packages/agenty-core/pkg/application/agent.go @@ -16,10 +16,10 @@ type AgentService struct { } type agentRepository interface { - Get(ctx context.Context, slug shared.Slug) (*agent.Agent, error) + Get(ctx context.Context, code shared.Code) (*agent.Agent, error) List(ctx context.Context) ([]*agent.Agent, error) Save(ctx context.Context, agent *agent.Agent) error - Delete(ctx context.Context, slug shared.Slug) error + Delete(ctx context.Context, code shared.Code) error } func NewAgentService(repo agentRepository) *AgentService { @@ -37,20 +37,20 @@ type AgentInput struct { Metadata shared.Metadata `json:"metadata,omitempty"` } -func (s *AgentService) Create(ctx context.Context, slug string, in AgentInput) (*agent.Agent, error) { - slugVal, err := shared.NewSlug(slug) +func (s *AgentService) Create(ctx context.Context, code string, in AgentInput) (*agent.Agent, error) { + codeVal, err := shared.NewCode(code) if err != nil { return nil, Validation(err.Error()) } - existing, err := s.repo.Get(ctx, slugVal) + existing, err := s.repo.Get(ctx, codeVal) if err == nil && existing != nil { - return nil, AlreadyExists("agent " + slug + " already exists") + return nil, AlreadyExists("agent " + code + " already exists") } else if err != nil && !errors.Is(err, storage.ErrAgentNotFound) { return nil, Internal("failed to check existing agent: " + err.Error()) } - a, err := agent.New(slug, in.Name) + a, err := agent.New(code, in.Name) if err != nil { return nil, Validation(err.Error()) } @@ -73,15 +73,15 @@ func (s *AgentService) Create(ctx context.Context, slug string, in AgentInput) ( return a, nil } -func (s *AgentService) Get(ctx context.Context, slug string) (*agent.Agent, error) { - slugVal, err := shared.NewSlug(slug) +func (s *AgentService) Get(ctx context.Context, code string) (*agent.Agent, error) { + codeVal, err := shared.NewCode(code) if err != nil { return nil, Validation(err.Error()) } - a, err := s.repo.Get(ctx, slugVal) + a, err := s.repo.Get(ctx, codeVal) if err != nil { if errors.Is(err, storage.ErrAgentNotFound) { - return nil, NotFound("agent " + slug + " not found") + return nil, NotFound("agent " + code + " not found") } return nil, Internal("failed to get agent: " + err.Error()) } @@ -110,15 +110,15 @@ type AgentUpdate struct { Metadata *shared.Metadata `json:"metadata,omitempty"` } -func (s *AgentService) Update(ctx context.Context, slug string, upd AgentUpdate) (*agent.Agent, error) { - slugVal, err := shared.NewSlug(slug) +func (s *AgentService) Update(ctx context.Context, code string, upd AgentUpdate) (*agent.Agent, error) { + codeVal, err := shared.NewCode(code) if err != nil { return nil, Validation(err.Error()) } - a, err := s.repo.Get(ctx, slugVal) + a, err := s.repo.Get(ctx, codeVal) if err != nil { if errors.Is(err, storage.ErrAgentNotFound) { - return nil, NotFound("agent " + slug + " not found") + return nil, NotFound("agent " + code + " not found") } return nil, Internal("failed to get agent: " + err.Error()) } @@ -158,14 +158,14 @@ func (s *AgentService) Update(ctx context.Context, slug string, upd AgentUpdate) return a, nil } -func (s *AgentService) Delete(ctx context.Context, slug string) error { - slugVal, err := shared.NewSlug(slug) +func (s *AgentService) Delete(ctx context.Context, code string) error { + codeVal, err := shared.NewCode(code) if err != nil { return Validation(err.Error()) } - if err := s.repo.Delete(ctx, slugVal); err != nil { + if err := s.repo.Delete(ctx, codeVal); err != nil { if errors.Is(err, storage.ErrAgentNotFound) { - return NotFound("agent " + slug + " not found") + return NotFound("agent " + code + " not found") } return Internal("failed to delete agent: " + err.Error()) } diff --git a/packages/agenty-core/pkg/application/agent_test.go b/packages/agenty-core/pkg/application/agent_test.go index c36e18b..858efee 100644 --- a/packages/agenty-core/pkg/application/agent_test.go +++ b/packages/agenty-core/pkg/application/agent_test.go @@ -22,8 +22,8 @@ func TestAgentCreateAndGet(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - if a.Slug.String() != "coder" { - t.Errorf("slug = %s, want coder", a.Slug) + if a.Code.String() != "coder" { + t.Errorf("code = %s, want coder", a.Code) } if a.Name != "Code Assistant" { t.Errorf("name = %s", a.Name) @@ -65,11 +65,11 @@ func TestAgentCreateDuplicate(t *testing.T) { } } -func TestAgentCreateInvalidSlug(t *testing.T) { +func TestAgentCreateInvalidCode(t *testing.T) { agentSvc, _, _ := newServices(t) - _, err := agentSvc.Create(context.Background(), "Bad Slug", application.AgentInput{Name: "A"}) + _, err := agentSvc.Create(context.Background(), "Bad Code", application.AgentInput{Name: "A"}) if code := appErrorCode(err); code != application.CodeValidation { - t.Errorf("invalid slug code = %v, want validation", code) + t.Errorf("invalid code code = %v, want validation", code) } } @@ -77,8 +77,8 @@ func TestAgentList(t *testing.T) { agentSvc, _, _ := newServices(t) ctx := context.Background() - for _, slug := range []string{"coder", "writer", "reviewer"} { - if _, err := agentSvc.Create(ctx, slug, application.AgentInput{Name: slug}); err != nil { + for _, code := range []string{"coder", "writer", "reviewer"} { + if _, err := agentSvc.Create(ctx, code, application.AgentInput{Name: code}); err != nil { t.Fatal(err) } } diff --git a/packages/agenty-core/pkg/application/initialize.go b/packages/agenty-core/pkg/application/initialize.go index 9e5755e..4520edd 100644 --- a/packages/agenty-core/pkg/application/initialize.go +++ b/packages/agenty-core/pkg/application/initialize.go @@ -39,34 +39,34 @@ func (s *InitializeService) Already(context.Context) InitializeAlreadyResult { } type InitializeCompleteInput struct { - AgentSlug string `json:"agentSlug"` - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + AgentCode string `json:"agentCode"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` } func (s *InitializeService) Complete( ctx context.Context, in InitializeCompleteInput, ) (InitializeAlreadyResult, error) { - a, err := s.agents.Get(ctx, in.AgentSlug) + a, err := s.agents.Get(ctx, in.AgentCode) if err != nil { return InitializeAlreadyResult{}, err } - p, err := s.providers.Get(ctx, in.ProviderSlug) + p, err := s.providers.Get(ctx, in.ProviderCode) if err != nil { return InitializeAlreadyResult{}, err } - modelSlug, err := shared.NewModelID(in.ModelSlug) + modelCode, err := shared.NewModelCode(in.ModelCode) if err != nil { return InitializeAlreadyResult{}, Validation(err.Error()) } - m, err := p.Model(modelSlug) + m, err := p.Model(modelCode) if err != nil { return InitializeAlreadyResult{}, NotFound( - fmt.Sprintf("model %s not found in provider %s", in.ModelSlug, in.ProviderSlug), + fmt.Sprintf("model %s not found in provider %s", in.ModelCode, in.ProviderCode), ) } - wantModel := shared.ModelRef{ProviderSlug: p.Slug, ModelSlug: m.Slug} + wantModel := shared.ModelRef{ProviderCode: p.Code, ModelCode: m.Code} if a.DefaultModel == nil || *a.DefaultModel != wantModel { return InitializeAlreadyResult{}, Validation("agent default model does not match the initialized provider and model") } diff --git a/packages/agenty-core/pkg/application/initialize_test.go b/packages/agenty-core/pkg/application/initialize_test.go index eb7c674..c18daa0 100644 --- a/packages/agenty-core/pkg/application/initialize_test.go +++ b/packages/agenty-core/pkg/application/initialize_test.go @@ -62,7 +62,7 @@ func TestInitializeServiceCompletesConfiguredResources(t *testing.T) { t.Fatalf("models = %d, want 1", len(provider.Models)) } - modelRef := &shared.ModelRef{ProviderSlug: "openai", ModelSlug: "gpt-test"} + modelRef := &shared.ModelRef{ProviderCode: "openai", ModelCode: "gpt-test"} agentResult, err := agents.Create(ctx, "default", application.AgentInput{ Name: "Default", Soul: "Be helpful.", @@ -78,9 +78,9 @@ func TestInitializeServiceCompletesConfiguredResources(t *testing.T) { } completed, err := svc.Complete(ctx, application.InitializeCompleteInput{ - AgentSlug: "default", - ProviderSlug: "openai", - ModelSlug: "gpt-test", + AgentCode: "default", + ProviderCode: "openai", + ModelCode: "gpt-test", }) if err != nil { t.Fatalf("Complete: %v", err) @@ -113,9 +113,9 @@ func TestInitializeServiceRejectsMismatchedAgentModel(t *testing.T) { } _, err = svc.Complete(ctx, application.InitializeCompleteInput{ - AgentSlug: "default", - ProviderSlug: "openai", - ModelSlug: "gpt-test", + AgentCode: "default", + ProviderCode: "openai", + ModelCode: "gpt-test", }) if code := appErrorCode(err); code != application.CodeValidation { t.Fatalf("error code = %v, want validation: %v", code, err) diff --git a/packages/agenty-core/pkg/application/provider.go b/packages/agenty-core/pkg/application/provider.go index e3deba8..c17b6ae 100644 --- a/packages/agenty-core/pkg/application/provider.go +++ b/packages/agenty-core/pkg/application/provider.go @@ -15,11 +15,10 @@ type ProviderService struct { } type providerRepository interface { - Get(ctx context.Context, slug shared.Slug) (*catalog.Provider, error) + Get(ctx context.Context, code shared.Code) (*catalog.Provider, error) List(ctx context.Context) ([]*catalog.Provider, error) Save(ctx context.Context, provider *catalog.Provider) error - Delete(ctx context.Context, slug shared.Slug) error - DeleteModel(ctx context.Context, providerSlug shared.Slug, modelSlug shared.ModelID) error + Delete(ctx context.Context, code shared.Code) error } func NewProviderService(repo providerRepository) *ProviderService { @@ -34,8 +33,8 @@ type ProviderInput struct { Metadata shared.Metadata `json:"metadata,omitempty"` } -func (s *ProviderService) Create(ctx context.Context, slug string, in ProviderInput) (*catalog.Provider, error) { - slugVal, err := shared.NewSlug(slug) +func (s *ProviderService) Create(ctx context.Context, code string, in ProviderInput) (*catalog.Provider, error) { + codeVal, err := shared.NewCode(code) if err != nil { return nil, Validation(err.Error()) } @@ -43,14 +42,14 @@ func (s *ProviderService) Create(ctx context.Context, slug string, in ProviderIn return nil, Validation("invalid api type: " + string(in.Type)) } - existing, err := s.repo.Get(ctx, slugVal) + existing, err := s.repo.Get(ctx, codeVal) if err == nil && existing != nil { - return nil, AlreadyExists("provider " + slug + " already exists") + return nil, AlreadyExists("provider " + code + " already exists") } else if err != nil && !errors.Is(err, storage.ErrProviderNotFound) { return nil, Internal("failed to check existing provider: " + err.Error()) } - p, err := catalog.NewProvider(slug, in.Name, in.Type) + p, err := catalog.NewProvider(code, in.Name, in.Type) if err != nil { return nil, Validation(err.Error()) } @@ -65,16 +64,16 @@ func (s *ProviderService) Create(ctx context.Context, slug string, in ProviderIn return p, nil } -func (s *ProviderService) Get(ctx context.Context, slug string) (*catalog.Provider, error) { - slugVal, err := shared.NewSlug(slug) +func (s *ProviderService) Get(ctx context.Context, code string) (*catalog.Provider, error) { + codeVal, err := shared.NewCode(code) if err != nil { return nil, Validation(err.Error()) } - p, err := s.repo.Get(ctx, slugVal) + p, err := s.repo.Get(ctx, codeVal) if err != nil { if errors.Is(err, storage.ErrProviderNotFound) { - return nil, NotFound("provider " + slug + " not found") + return nil, NotFound("provider " + code + " not found") } return nil, Internal("failed to get provider: " + err.Error()) } @@ -100,16 +99,16 @@ type ProviderUpdate struct { Metadata *shared.Metadata `json:"metadata,omitempty"` } -func (s *ProviderService) Update(ctx context.Context, slug string, upd ProviderUpdate) (*catalog.Provider, error) { - slugVal, err := shared.NewSlug(slug) +func (s *ProviderService) Update(ctx context.Context, code string, upd ProviderUpdate) (*catalog.Provider, error) { + codeVal, err := shared.NewCode(code) if err != nil { return nil, Validation(err.Error()) } - p, err := s.repo.Get(ctx, slugVal) + p, err := s.repo.Get(ctx, codeVal) if err != nil { if errors.Is(err, storage.ErrProviderNotFound) { - return nil, NotFound("provider " + slug + " not found") + return nil, NotFound("provider " + code + " not found") } return nil, Internal("failed to get provider: " + err.Error()) } @@ -140,15 +139,15 @@ func (s *ProviderService) Update(ctx context.Context, slug string, upd ProviderU return p, nil } -func (s *ProviderService) Delete(ctx context.Context, slug string) error { - slugVal, err := shared.NewSlug(slug) +func (s *ProviderService) Delete(ctx context.Context, code string) error { + codeVal, err := shared.NewCode(code) if err != nil { return Validation(err.Error()) } - if err := s.repo.Delete(ctx, slugVal); err != nil { + if err := s.repo.Delete(ctx, codeVal); err != nil { if errors.Is(err, storage.ErrProviderNotFound) { - return NotFound("provider " + slug + " not found") + return NotFound("provider " + code + " not found") } return Internal("failed to delete provider: " + err.Error()) } @@ -167,13 +166,13 @@ type ModelInput struct { IsDefault bool `json:"isDefault,omitempty"` } -func (s *ProviderService) AddModel(ctx context.Context, providerSlug, modelSlug string, in ModelInput) (*catalog.Provider, error) { - ps, err := shared.NewSlug(providerSlug) +func (s *ProviderService) AddModel(ctx context.Context, providerCode, modelCode string, in ModelInput) (*catalog.Provider, error) { + ps, err := shared.NewCode(providerCode) if err != nil { return nil, Validation(err.Error()) } - ms, err := shared.NewModelID(modelSlug) + ms, err := shared.NewModelCode(modelCode) if err != nil { return nil, Validation(err.Error()) } @@ -189,14 +188,14 @@ func (s *ProviderService) AddModel(ctx context.Context, providerSlug, modelSlug p, err := s.repo.Get(ctx, ps) if err != nil { if errors.Is(err, storage.ErrProviderNotFound) { - return nil, NotFound("provider " + providerSlug + " not found") + return nil, NotFound("provider " + providerCode + " not found") } return nil, Internal("failed to get provider: " + err.Error()) } now := time.Now().UTC() p.AddModel(catalog.Model{ - Slug: ms, + Code: ms, Name: in.Name, ContextWindow: in.ContextWindow, MaxOutputTokens: catalog.DefaultMaxOutputTokens, @@ -215,31 +214,36 @@ func (s *ProviderService) AddModel(ctx context.Context, providerSlug, modelSlug return p, nil } -func (s *ProviderService) RemoveModel(ctx context.Context, providerSlug, modelSlug string) (*catalog.Provider, error) { - ps, err := shared.NewSlug(providerSlug) +func (s *ProviderService) RemoveModel(ctx context.Context, providerCode, modelCode string) (*catalog.Provider, error) { + ps, err := shared.NewCode(providerCode) if err != nil { return nil, Validation(err.Error()) } - ms, err := shared.NewModelID(modelSlug) + ms, err := shared.NewModelCode(modelCode) if err != nil { return nil, Validation(err.Error()) } - if err := s.repo.DeleteModel(ctx, ps, ms); err != nil { - switch { - case errors.Is(err, storage.ErrProviderNotFound): - return nil, NotFound("provider " + providerSlug + " not found") - case errors.Is(err, catalog.ErrModelNotFound): - return nil, NotFound("model " + modelSlug + " not found in provider " + providerSlug) - default: - return nil, Internal("failed to remove model: " + err.Error()) + p, err := s.repo.Get(ctx, ps) + if err != nil { + if errors.Is(err, storage.ErrProviderNotFound) { + return nil, NotFound("provider " + providerCode + " not found") } + return nil, Internal("failed to get provider: " + err.Error()) } - p, err := s.repo.Get(ctx, ps) - if err != nil { - return nil, Internal("failed to reload provider: " + err.Error()) + if _, err := p.Model(ms); err != nil { + if errors.Is(err, catalog.ErrModelNotFound) { + return nil, NotFound("model " + modelCode + " not found in provider " + providerCode) + } + return nil, Internal("failed to find model: " + err.Error()) + } + + p.RemoveModel(ms) + p.UpdatedAt = time.Now().UTC() + if err := s.repo.Save(ctx, p); err != nil { + return nil, Internal("failed to save provider: " + err.Error()) } return p, nil } diff --git a/packages/agenty-core/pkg/application/provider_test.go b/packages/agenty-core/pkg/application/provider_test.go index 3fab238..812bb85 100644 --- a/packages/agenty-core/pkg/application/provider_test.go +++ b/packages/agenty-core/pkg/application/provider_test.go @@ -22,8 +22,8 @@ func TestProviderCreateAndGet(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - if p.Slug.String() != "anthropic" { - t.Errorf("slug = %s", p.Slug) + if p.Code.String() != "anthropic" { + t.Errorf("code = %s", p.Code) } got, err := providerSvc.Get(ctx, "anthropic") @@ -64,8 +64,8 @@ func TestProviderList(t *testing.T) { _, providerSvc, _ := newServices(t) ctx := context.Background() - for _, slug := range []string{"anthropic", "openai"} { - if _, err := providerSvc.Create(ctx, slug, application.ProviderInput{Name: slug, Type: catalog.APIOpenAI}); err != nil { + for _, code := range []string{"anthropic", "openai"} { + if _, err := providerSvc.Create(ctx, code, application.ProviderInput{Name: code, Type: catalog.APIOpenAI}); err != nil { t.Fatal(err) } } @@ -139,7 +139,7 @@ func TestProviderAddModelAndRemoveModel(t *testing.T) { t.Errorf("max output tokens = %d, want %d", p.Models[0].MaxOutputTokens, catalog.DefaultMaxOutputTokens) } - // AddModel is upsert: re-adding the same slug replaces. + // AddModel is upsert: re-adding the same code replaces. if _, err := providerSvc.AddModel(ctx, "anthropic", "claude-opus-4-8", application.ModelInput{ Name: "Claude Opus 4.8 Updated", ContextWindow: 210_000, @@ -158,23 +158,23 @@ func TestProviderAddModelAndRemoveModel(t *testing.T) { t.Errorf("model name = %s, want updated", p.Models[0].Name) } - // Model IDs may include provider namespaces, underscores and variant markers. - const namespacedModelID = "org/model_name[v2]" - if _, err := providerSvc.AddModel(ctx, "anthropic", namespacedModelID, application.ModelInput{ + // Model Codes may include provider namespaces, underscores and variant markers. + const namespacedModelCode = "org/model_name[v2]" + if _, err := providerSvc.AddModel(ctx, "anthropic", namespacedModelCode, application.ModelInput{ Name: "Namespaced model", MaxOutputTokens: 16_384, }); err != nil { - t.Fatalf("AddModel namespaced ID: %v", err) + t.Fatalf("AddModel namespaced code: %v", err) } p, err = providerSvc.Get(ctx, "anthropic") if err != nil { t.Fatal(err) } - if _, err := p.Model(mustModelIDForTest(namespacedModelID)); err != nil { + if _, err := p.Model(mustModelCodeForTest(namespacedModelCode)); err != nil { t.Fatalf("namespaced model lookup: %v", err) } - if _, err := providerSvc.RemoveModel(ctx, "anthropic", namespacedModelID); err != nil { - t.Fatalf("RemoveModel namespaced ID: %v", err) + if _, err := providerSvc.RemoveModel(ctx, "anthropic", namespacedModelCode); err != nil { + t.Fatalf("RemoveModel namespaced code: %v", err) } // Add a second model, then remove the first. @@ -192,8 +192,8 @@ func TestProviderAddModelAndRemoveModel(t *testing.T) { if len(p.Models) != 1 { t.Fatalf("after remove has %d models, want 1", len(p.Models)) } - if p.Models[0].Slug.String() != "claude-haiku-4-5" { - t.Errorf("remaining model = %s, want claude-haiku-4-5", p.Models[0].Slug) + if p.Models[0].Code.String() != "claude-haiku-4-5" { + t.Errorf("remaining model = %s, want claude-haiku-4-5", p.Models[0].Code) } // Removing again surfaces not-found. @@ -203,12 +203,12 @@ func TestProviderAddModelAndRemoveModel(t *testing.T) { } } -func mustModelIDForTest(value string) shared.ModelID { - modelID, err := shared.NewModelID(value) +func mustModelCodeForTest(value string) shared.ModelCode { + modelCode, err := shared.NewModelCode(value) if err != nil { panic(err) } - return modelID + return modelCode } func TestProviderAddModelRejectsInvalidReasoningEffortMapping(t *testing.T) { diff --git a/packages/agenty-core/pkg/application/session.go b/packages/agenty-core/pkg/application/session.go index c430d80..35e3ef6 100644 --- a/packages/agenty-core/pkg/application/session.go +++ b/packages/agenty-core/pkg/application/session.go @@ -45,24 +45,24 @@ func NewSessionService(repo sessionRepository, options ...SessionServiceOption) } type SessionCreateInput struct { - AgentSlug string `json:"agentSlug"` - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + AgentCode string `json:"agentCode"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` ContextWindow int64 `json:"contextWindow,omitempty"` ReasoningEffort shared.ReasoningEffort `json:"reasoningEffort,omitempty"` Cwd *string `json:"cwd,omitempty"` } func (s *SessionService) Create(ctx context.Context, in SessionCreateInput) (*conversation.Session, error) { - agentSlug, err := shared.NewSlug(in.AgentSlug) + agentCode, err := shared.NewCode(in.AgentCode) if err != nil { return nil, Validation(err.Error()) } - providerSlug, err := shared.NewSlug(in.ProviderSlug) + providerCode, err := shared.NewCode(in.ProviderCode) if err != nil { return nil, Validation(err.Error()) } - modelSlug, err := shared.NewModelID(in.ModelSlug) + modelCode, err := shared.NewModelCode(in.ModelCode) if err != nil { return nil, Validation(err.Error()) } @@ -76,8 +76,8 @@ func (s *SessionService) Create(ctx context.Context, in SessionCreateInput) (*co } session := conversation.StartSession( - agentSlug, - shared.NewModelRef(providerSlug, modelSlug), + agentCode, + shared.NewModelRef(providerCode, modelCode), in.ContextWindow, effort, in.Cwd, @@ -106,22 +106,22 @@ func (s *SessionService) Get(ctx context.Context, idStr string) (*conversation.S } type SessionListQuery struct { - AgentSlug string + AgentCode string Limit int Offset int } func (s *SessionService) List(ctx context.Context, q SessionListQuery) ([]conversation.SessionSummary, error) { - var agentSlug *shared.Slug - if q.AgentSlug != "" { - sv, err := shared.NewSlug(q.AgentSlug) + var agentCode *shared.Code + if q.AgentCode != "" { + sv, err := shared.NewCode(q.AgentCode) if err != nil { return nil, Validation(err.Error()) } - agentSlug = &sv + agentCode = &sv } - sums, err := s.repo.List(ctx, conversation.ListQuery{AgentSlug: agentSlug, Limit: q.Limit, Offset: q.Offset}) + sums, err := s.repo.List(ctx, conversation.ListQuery{AgentCode: agentCode, Limit: q.Limit, Offset: q.Offset}) if err != nil { return nil, Internal("failed to list sessions: " + err.Error()) } @@ -170,16 +170,16 @@ func (s *SessionService) SetTitle(ctx context.Context, idStr, title string) (*co return s.saveUpdated(ctx, sess) } -func (s *SessionService) SetModel(ctx context.Context, idStr, providerSlug, modelSlug string, contextWindow int64) (*conversation.Session, error) { +func (s *SessionService) SetModel(ctx context.Context, idStr, providerCode, modelCode string, contextWindow int64) (*conversation.Session, error) { sess, err := s.loadForUpdate(ctx, idStr) if err != nil { return nil, err } - ps, err := shared.NewSlug(providerSlug) + ps, err := shared.NewCode(providerCode) if err != nil { return nil, Validation(err.Error()) } - ms, err := shared.NewModelID(modelSlug) + ms, err := shared.NewModelCode(modelCode) if err != nil { return nil, Validation(err.Error()) } diff --git a/packages/agenty-core/pkg/application/session_test.go b/packages/agenty-core/pkg/application/session_test.go index 834949c..5d137ae 100644 --- a/packages/agenty-core/pkg/application/session_test.go +++ b/packages/agenty-core/pkg/application/session_test.go @@ -9,12 +9,12 @@ import ( "github.com/masteryyh/agenty-core/pkg/domain/shared" ) -func newSession(t *testing.T, sessionSvc *application.SessionService, agentSlug string) string { +func newSession(t *testing.T, sessionSvc *application.SessionService, agentCode string) string { t.Helper() sess, err := sessionSvc.Create(context.Background(), application.SessionCreateInput{ - AgentSlug: agentSlug, - ProviderSlug: "anthropic", - ModelSlug: "claude-opus-4-8", + AgentCode: agentCode, + ProviderCode: "anthropic", + ModelCode: "claude-opus-4-8", ContextWindow: 200_000, }) if err != nil { @@ -28,9 +28,9 @@ func TestSessionCreateAndGet(t *testing.T) { ctx := context.Background() sess, err := sessionSvc.Create(ctx, application.SessionCreateInput{ - AgentSlug: "coder", - ProviderSlug: "anthropic", - ModelSlug: "claude-opus-4-8", + AgentCode: "coder", + ProviderCode: "anthropic", + ModelCode: "claude-opus-4-8", ContextWindow: 200_000, ReasoningEffort: shared.ReasoningHigh, Cwd: ptr("/tmp/work"), @@ -41,7 +41,7 @@ func TestSessionCreateAndGet(t *testing.T) { if sess.ID.String() == "" { t.Error("session id is empty") } - if sess.CurrentModel == nil || sess.CurrentModel.ModelSlug.String() != "claude-opus-4-8" { + if sess.CurrentModel == nil || sess.CurrentModel.ModelCode.String() != "claude-opus-4-8" { t.Errorf("current model = %+v", sess.CurrentModel) } if sess.Cwd == nil || *sess.Cwd != "/tmp/work" { @@ -104,10 +104,10 @@ func TestSessionCreateRejectsInvalidInput(t *testing.T) { name string input application.SessionCreateInput }{ - {name: "agent slug", input: application.SessionCreateInput{AgentSlug: "Bad Slug", ProviderSlug: "anthropic", ModelSlug: "claude-opus"}}, - {name: "provider slug", input: application.SessionCreateInput{AgentSlug: "coder", ProviderSlug: "Bad Slug", ModelSlug: "claude-opus"}}, - {name: "model slug", input: application.SessionCreateInput{AgentSlug: "coder", ProviderSlug: "anthropic", ModelSlug: "Bad Slug"}}, - {name: "reasoning effort", input: application.SessionCreateInput{AgentSlug: "coder", ProviderSlug: "anthropic", ModelSlug: "claude-opus", ReasoningEffort: "extreme"}}, + {name: "agent code", input: application.SessionCreateInput{AgentCode: "Bad Code", ProviderCode: "anthropic", ModelCode: "claude-opus"}}, + {name: "provider code", input: application.SessionCreateInput{AgentCode: "coder", ProviderCode: "Bad Code", ModelCode: "claude-opus"}}, + {name: "model code", input: application.SessionCreateInput{AgentCode: "coder", ProviderCode: "anthropic", ModelCode: "Bad Code"}}, + {name: "reasoning effort", input: application.SessionCreateInput{AgentCode: "coder", ProviderCode: "anthropic", ModelCode: "claude-opus", ReasoningEffort: "extreme"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -124,7 +124,7 @@ func TestSessionCreateRejectsInvalidInput(t *testing.T) { func TestSessionCreateDefaultsReasoningOff(t *testing.T) { _, _, sessionSvc := newServices(t) sess, err := sessionSvc.Create(t.Context(), application.SessionCreateInput{ - AgentSlug: "coder", ProviderSlug: "anthropic", ModelSlug: "claude-opus", + AgentCode: "coder", ProviderCode: "anthropic", ModelCode: "claude-opus", }) if err != nil { t.Fatal(err) @@ -150,7 +150,7 @@ func TestSessionList(t *testing.T) { t.Errorf("List all returned %d, want 3", len(all)) } - filtered, err := sessionSvc.List(ctx, application.SessionListQuery{AgentSlug: "coder"}) + filtered, err := sessionSvc.List(ctx, application.SessionListQuery{AgentCode: "coder"}) if err != nil { t.Fatalf("List filtered: %v", err) } @@ -166,7 +166,7 @@ func TestSessionList(t *testing.T) { t.Errorf("List paged returned %d, want 1", len(paged)) } - if _, err := sessionSvc.List(ctx, application.SessionListQuery{AgentSlug: "Bad Slug"}); appErrorCode(err) != application.CodeValidation { + if _, err := sessionSvc.List(ctx, application.SessionListQuery{AgentCode: "Bad Code"}); appErrorCode(err) != application.CodeValidation { t.Errorf("invalid filter error = %v, want validation", err) } } @@ -200,7 +200,7 @@ func TestSessionSetModel(t *testing.T) { if err != nil { t.Fatal(err) } - if got.CurrentModel == nil || got.CurrentModel.ModelSlug.String() != "gpt-5.6" { + if got.CurrentModel == nil || got.CurrentModel.ModelCode.String() != "gpt-5.6" { t.Errorf("current model = %+v, want gpt-5.6", got.CurrentModel) } if got.ContextWindow != 128_000 { diff --git a/packages/agenty-core/pkg/application/testhelper_test.go b/packages/agenty-core/pkg/application/testhelper_test.go index 7305def..362b4fb 100644 --- a/packages/agenty-core/pkg/application/testhelper_test.go +++ b/packages/agenty-core/pkg/application/testhelper_test.go @@ -20,7 +20,7 @@ import ( ) type agentRepositoryFake struct { - agents map[shared.Slug]*agent.Agent + agents map[shared.Code]*agent.Agent getErr error listErr error saveErr error @@ -28,14 +28,14 @@ type agentRepositoryFake struct { } func newAgentRepositoryFake() *agentRepositoryFake { - return &agentRepositoryFake{agents: make(map[shared.Slug]*agent.Agent)} + return &agentRepositoryFake{agents: make(map[shared.Code]*agent.Agent)} } -func (r *agentRepositoryFake) Get(_ context.Context, slug shared.Slug) (*agent.Agent, error) { +func (r *agentRepositoryFake) Get(_ context.Context, code shared.Code) (*agent.Agent, error) { if r.getErr != nil { return nil, r.getErr } - a, ok := r.agents[slug] + a, ok := r.agents[code] if !ok { return nil, storage.ErrAgentNotFound } @@ -57,18 +57,18 @@ func (r *agentRepositoryFake) Save(_ context.Context, a *agent.Agent) error { if r.saveErr != nil { return r.saveErr } - r.agents[a.Slug] = cloneAgent(a) + r.agents[a.Code] = cloneAgent(a) return nil } -func (r *agentRepositoryFake) Delete(_ context.Context, slug shared.Slug) error { +func (r *agentRepositoryFake) Delete(_ context.Context, code shared.Code) error { if r.deleteErr != nil { return r.deleteErr } - if _, ok := r.agents[slug]; !ok { + if _, ok := r.agents[code]; !ok { return storage.ErrAgentNotFound } - delete(r.agents, slug) + delete(r.agents, code) return nil } @@ -83,23 +83,22 @@ func cloneAgent(a *agent.Agent) *agent.Agent { } type providerRepositoryFake struct { - providers map[shared.Slug]*catalog.Provider - getErr error - listErr error - saveErr error - deleteErr error - deleteModelErr error + providers map[shared.Code]*catalog.Provider + getErr error + listErr error + saveErr error + deleteErr error } func newProviderRepositoryFake() *providerRepositoryFake { - return &providerRepositoryFake{providers: make(map[shared.Slug]*catalog.Provider)} + return &providerRepositoryFake{providers: make(map[shared.Code]*catalog.Provider)} } -func (r *providerRepositoryFake) Get(_ context.Context, slug shared.Slug) (*catalog.Provider, error) { +func (r *providerRepositoryFake) Get(_ context.Context, code shared.Code) (*catalog.Provider, error) { if r.getErr != nil { return nil, r.getErr } - p, ok := r.providers[slug] + p, ok := r.providers[code] if !ok { return nil, storage.ErrProviderNotFound } @@ -121,38 +120,21 @@ func (r *providerRepositoryFake) Save(_ context.Context, p *catalog.Provider) er if r.saveErr != nil { return r.saveErr } - r.providers[p.Slug] = cloneProvider(p) + r.providers[p.Code] = cloneProvider(p) return nil } -func (r *providerRepositoryFake) Delete(_ context.Context, slug shared.Slug) error { +func (r *providerRepositoryFake) Delete(_ context.Context, code shared.Code) error { if r.deleteErr != nil { return r.deleteErr } - if _, ok := r.providers[slug]; !ok { + if _, ok := r.providers[code]; !ok { return storage.ErrProviderNotFound } - delete(r.providers, slug) + delete(r.providers, code) return nil } -func (r *providerRepositoryFake) DeleteModel(_ context.Context, providerSlug shared.Slug, modelSlug shared.ModelID) error { - if r.deleteModelErr != nil { - return r.deleteModelErr - } - p, ok := r.providers[providerSlug] - if !ok { - return storage.ErrProviderNotFound - } - for i := range p.Models { - if p.Models[i].Slug == modelSlug { - p.Models = append(p.Models[:i], p.Models[i+1:]...) - return nil - } - } - return catalog.ErrModelNotFound -} - func cloneProvider(p *catalog.Provider) *catalog.Provider { copy := *p copy.Models = slices.Clone(p.Models) @@ -218,7 +200,7 @@ func (r *sessionRepositoryFake) List(_ context.Context, query conversation.ListQ result := make([]conversation.SessionSummary, 0, len(r.events)) for _, events := range r.events { summary := conversation.ReplaySession(events).Summary() - if query.AgentSlug == nil || summary.AgentSlug == *query.AgentSlug { + if query.AgentCode == nil || summary.AgentCode == *query.AgentCode { result = append(result, summary) } } diff --git a/packages/agenty-core/pkg/domain/agent/agent.go b/packages/agenty-core/pkg/domain/agent/agent.go index b21e866..dc228d3 100644 --- a/packages/agenty-core/pkg/domain/agent/agent.go +++ b/packages/agenty-core/pkg/domain/agent/agent.go @@ -38,7 +38,7 @@ var baseSystemPromptTemplate = template.Must( ) type Agent struct { - Slug shared.Slug `json:"slug"` + Code shared.Code `json:"code"` Name string `json:"name"` Description string `json:"description,omitempty"` Soul string `json:"soul"` @@ -51,15 +51,15 @@ type Agent struct { UpdatedAt time.Time `json:"updatedAt"` } -func New(slug, name string) (*Agent, error) { - s, err := shared.NewSlug(slug) +func New(code, name string) (*Agent, error) { + s, err := shared.NewCode(code) if err != nil { return nil, err } now := time.Now().UTC() return &Agent{ - Slug: s, + Code: s, Name: name, CreatedAt: now, UpdatedAt: now, diff --git a/packages/agenty-core/pkg/domain/agent/repository.go b/packages/agenty-core/pkg/domain/agent/repository.go index 287a64f..e092147 100644 --- a/packages/agenty-core/pkg/domain/agent/repository.go +++ b/packages/agenty-core/pkg/domain/agent/repository.go @@ -10,9 +10,9 @@ import ( var ErrNotFound = errors.New("agent: not found") type Repository interface { - Get(ctx context.Context, slug shared.Slug) (*Agent, error) + Get(ctx context.Context, code shared.Code) (*Agent, error) List(ctx context.Context) ([]*Agent, error) Save(ctx context.Context, agent *Agent) error - Delete(ctx context.Context, slug shared.Slug) error + Delete(ctx context.Context, code shared.Code) error Default(ctx context.Context) (*Agent, error) } diff --git a/packages/agenty-core/pkg/domain/catalog/model.go b/packages/agenty-core/pkg/domain/catalog/model.go index 97dfb09..7c01d31 100644 --- a/packages/agenty-core/pkg/domain/catalog/model.go +++ b/packages/agenty-core/pkg/domain/catalog/model.go @@ -9,7 +9,7 @@ import ( const DefaultMaxOutputTokens int64 = 8_192 type Model struct { - Slug shared.ModelID `json:"slug"` + Code shared.ModelCode `json:"code"` Name string `json:"name"` ContextWindow int `json:"contextWindow"` MaxOutputTokens int64 `json:"maxOutputTokens"` diff --git a/packages/agenty-core/pkg/domain/catalog/provider.go b/packages/agenty-core/pkg/domain/catalog/provider.go index 2f40cd2..fd631ee 100644 --- a/packages/agenty-core/pkg/domain/catalog/provider.go +++ b/packages/agenty-core/pkg/domain/catalog/provider.go @@ -12,7 +12,7 @@ var ( ) type Provider struct { - Slug shared.Slug `json:"slug"` + Code shared.Code `json:"code"` Name string `json:"name"` Type APIType `json:"type"` BaseURL string `json:"baseUrl"` @@ -23,8 +23,8 @@ type Provider struct { UpdatedAt time.Time `json:"updatedAt"` } -func NewProvider(slug, name string, apiType APIType) (*Provider, error) { - s, err := shared.NewSlug(slug) +func NewProvider(code, name string, apiType APIType) (*Provider, error) { + s, err := shared.NewCode(code) if err != nil { return nil, err } @@ -34,7 +34,7 @@ func NewProvider(slug, name string, apiType APIType) (*Provider, error) { now := time.Now().UTC() return &Provider{ - Slug: s, + Code: s, Name: name, Type: apiType, Models: make([]Model, 0), @@ -43,9 +43,9 @@ func NewProvider(slug, name string, apiType APIType) (*Provider, error) { }, nil } -func (p *Provider) Model(slug shared.ModelID) (*Model, error) { +func (p *Provider) Model(code shared.ModelCode) (*Model, error) { for i := range p.Models { - if p.Models[i].Slug == slug { + if p.Models[i].Code == code { return &p.Models[i], nil } } @@ -55,7 +55,7 @@ func (p *Provider) Model(slug shared.ModelID) (*Model, error) { func (p *Provider) AddModel(m Model) { m.MaxOutputTokens = DefaultMaxOutputTokens for i := range p.Models { - if p.Models[i].Slug == m.Slug { + if p.Models[i].Code == m.Code { p.Models[i] = m return } @@ -63,9 +63,9 @@ func (p *Provider) AddModel(m Model) { p.Models = append(p.Models, m) } -func (p *Provider) RemoveModel(slug shared.ModelID) { +func (p *Provider) RemoveModel(code shared.ModelCode) { for i := range p.Models { - if p.Models[i].Slug == slug { + if p.Models[i].Code == code { p.Models = append(p.Models[:i], p.Models[i+1:]...) return } diff --git a/packages/agenty-core/pkg/domain/catalog/provider_test.go b/packages/agenty-core/pkg/domain/catalog/provider_test.go index 4931e04..b6ad4af 100644 --- a/packages/agenty-core/pkg/domain/catalog/provider_test.go +++ b/packages/agenty-core/pkg/domain/catalog/provider_test.go @@ -11,8 +11,8 @@ func TestProvider_ModelLifecycle(t *testing.T) { t.Parallel() p := &Provider{Models: []Model{ - {Slug: "model-a", Name: "A", IsDefault: true}, - {Slug: "model-b", Name: "B"}, + {Code: "model-a", Name: "A", IsDefault: true}, + {Code: "model-b", Name: "B"}, }} got, err := p.Model("model-b") @@ -24,7 +24,7 @@ func TestProvider_ModelLifecycle(t *testing.T) { } p.AddModel(Model{ - Slug: "model-b", + Code: "model-b", Name: "B2", ReasoningEffortMapping: map[string]shared.ReasoningEffort{ "high": shared.ReasoningHigh, @@ -42,7 +42,7 @@ func TestProvider_ModelLifecycle(t *testing.T) { } defaultModel, ok := p.DefaultModel() - if !ok || defaultModel.Slug != "model-a" { + if !ok || defaultModel.Code != "model-a" { t.Errorf("default model = %+v, %v", defaultModel, ok) } diff --git a/packages/agenty-core/pkg/domain/catalog/repository.go b/packages/agenty-core/pkg/domain/catalog/repository.go index 1a25a29..391a5fa 100644 --- a/packages/agenty-core/pkg/domain/catalog/repository.go +++ b/packages/agenty-core/pkg/domain/catalog/repository.go @@ -10,8 +10,8 @@ import ( var ErrProviderNotFound = errors.New("catalog: provider not found") type Repository interface { - Get(ctx context.Context, slug shared.Slug) (*Provider, error) + Get(ctx context.Context, code shared.Code) (*Provider, error) List(ctx context.Context) ([]*Provider, error) Save(ctx context.Context, provider *Provider) error - Delete(ctx context.Context, slug shared.Slug) error + Delete(ctx context.Context, code shared.Code) error } diff --git a/packages/agenty-core/pkg/domain/conversation/compaction.go b/packages/agenty-core/pkg/domain/conversation/compaction.go index 320955c..996bab6 100644 --- a/packages/agenty-core/pkg/domain/conversation/compaction.go +++ b/packages/agenty-core/pkg/domain/conversation/compaction.go @@ -79,7 +79,7 @@ func retainedAssistantMessage(message Message) (Message, bool) { content := make(Content, 0, len(message.Content)) for _, block := range message.Content { switch block.(type) { - case ReasoningBlock, ToolUseBlock, ShellCallBlock: + case ReasoningBlock, ToolUseBlock, ShellCallBlock, ApplyPatchCallBlock: continue default: content = append(content, block) @@ -166,8 +166,8 @@ func (s *Session) updateMetadataModel(model shared.ModelRef) { if s.metadata == nil || !s.hasCompactionSummary() { return } - s.metadata.Model = model.ModelSlug.String() - s.metadata.Provider = model.ProviderSlug.String() + s.metadata.Model = model.ModelCode.String() + s.metadata.Provider = model.ProviderCode.String() } func (s *Session) updateMetadataReasoningEffort(effort shared.ReasoningEffort) { diff --git a/packages/agenty-core/pkg/domain/conversation/content.go b/packages/agenty-core/pkg/domain/conversation/content.go index ca5979d..de3b0fe 100644 --- a/packages/agenty-core/pkg/domain/conversation/content.go +++ b/packages/agenty-core/pkg/domain/conversation/content.go @@ -18,6 +18,7 @@ const ( BlockImage BlockType = "image" BlockShellCall BlockType = "shell_call" BlockShellOutput BlockType = "shell_call_output" + BlockApplyPatch BlockType = "apply_patch_call" ) type ContentBlock interface { @@ -157,6 +158,56 @@ type ShellCallOutputBlock struct { Output []ShellCommandOutput `json:"output"` } +type ApplyPatchOperationType string + +const ( + ApplyPatchCreateFile ApplyPatchOperationType = "create_file" + ApplyPatchUpdateFile ApplyPatchOperationType = "update_file" + ApplyPatchDeleteFile ApplyPatchOperationType = "delete_file" +) + +type ApplyPatchOperation struct { + Type ApplyPatchOperationType `json:"type"` + Path string `json:"path"` + Diff string `json:"diff,omitempty"` + MoveTo string `json:"moveTo,omitempty"` +} + +type ApplyPatchCallSource string + +const ( + ApplyPatchSourceNative ApplyPatchCallSource = "native" + ApplyPatchSourceCustom ApplyPatchCallSource = "custom" +) + +type ApplyPatchCallBlock struct { + ID string `json:"id,omitempty"` + CallID string `json:"callId"` + Source ApplyPatchCallSource `json:"source"` + Operation *ApplyPatchOperation `json:"operation,omitempty"` + Patch string `json:"patch,omitempty"` +} + +func (b ApplyPatchCallBlock) ToolUseBlock() ToolUseBlock { + input, _ := json.Marshal(struct { + Operation *ApplyPatchOperation `json:"operation,omitempty"` + Patch string `json:"patch,omitempty"` + }{Operation: b.Operation, Patch: b.Patch}) + return ToolUseBlock{ID: b.CallID, Name: "apply_patch", Input: shared.RawJSON(input)} +} + +func (ApplyPatchCallBlock) BlockType() BlockType { + return BlockApplyPatch +} + +func (b ApplyPatchCallBlock) MarshalJSON() ([]byte, error) { + type alias ApplyPatchCallBlock + return json.Marshal(struct { + Type BlockType `json:"type"` + alias + }{Type: BlockApplyPatch, alias: alias(b)}) +} + func (ShellCallOutputBlock) BlockType() BlockType { return BlockShellOutput } @@ -245,6 +296,12 @@ func (c *Content) UnmarshalJSON(data []byte) error { return err } block = b + case BlockApplyPatch: + var b ApplyPatchCallBlock + if err := json.Unmarshal(raw, &b); err != nil { + return err + } + block = b case BlockImage: var b ImageBlock if err := json.Unmarshal(raw, &b); err != nil { diff --git a/packages/agenty-core/pkg/domain/conversation/content_test.go b/packages/agenty-core/pkg/domain/conversation/content_test.go index 723e00d..48ef8e8 100644 --- a/packages/agenty-core/pkg/domain/conversation/content_test.go +++ b/packages/agenty-core/pkg/domain/conversation/content_test.go @@ -122,6 +122,54 @@ func TestShellBlocksRoundTrip(t *testing.T) { } } +func TestApplyPatchBlocksRoundTrip(t *testing.T) { + t.Parallel() + + operation := ApplyPatchOperation{ + Type: ApplyPatchUpdateFile, + Path: "main.go", + Diff: "@@\n-old\n+new", + } + original := Content{ + ApplyPatchCallBlock{ + ID: "apc_1", CallID: "call_1", Source: ApplyPatchSourceNative, + Operation: &operation, + }, + ApplyPatchCallBlock{ + CallID: "call_2", Source: ApplyPatchSourceCustom, + Patch: "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch", + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var decoded Content + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + native, ok := decoded[0].(ApplyPatchCallBlock) + if !ok || native.Operation == nil || native.Operation.Diff != operation.Diff { + t.Fatalf("native block = %#v", decoded[0]) + } + custom, ok := decoded[1].(ApplyPatchCallBlock) + if !ok || custom.Patch != original[1].(ApplyPatchCallBlock).Patch { + t.Fatalf("custom block = %#v", decoded[1]) + } + + nativeInput := native.ToolUseBlock() + if nativeInput.ID != "call_1" || nativeInput.Name != "apply_patch" || + string(nativeInput.Input) != `{"operation":{"type":"update_file","path":"main.go","diff":"@@\n-old\n+new"}}` { + t.Errorf("native tool input = %#v", nativeInput) + } + customInput := custom.ToolUseBlock() + if customInput.ID != "call_2" || + string(customInput.Input) != `{"patch":"*** Begin Patch\n*** Delete File: old.txt\n*** End Patch"}` { + t.Errorf("custom tool input = %#v", customInput) + } +} + func int64Pointer(value int64) *int64 { return &value } diff --git a/packages/agenty-core/pkg/domain/conversation/events.go b/packages/agenty-core/pkg/domain/conversation/events.go index 186619e..0ed7e86 100644 --- a/packages/agenty-core/pkg/domain/conversation/events.go +++ b/packages/agenty-core/pkg/domain/conversation/events.go @@ -27,7 +27,7 @@ const ( type SessionStarted struct { SessionID uuid.UUID `json:"sessionId"` - Agent shared.Slug `json:"agent"` + Agent shared.Code `json:"agent"` Model shared.ModelRef `json:"model"` ContextWindow int64 `json:"contextWindow"` ReasoningEffort shared.ReasoningEffort `json:"reasoningEffort,omitempty"` diff --git a/packages/agenty-core/pkg/domain/conversation/repository.go b/packages/agenty-core/pkg/domain/conversation/repository.go index 1149f68..917db34 100644 --- a/packages/agenty-core/pkg/domain/conversation/repository.go +++ b/packages/agenty-core/pkg/domain/conversation/repository.go @@ -13,8 +13,8 @@ var ErrSessionNotFound = errors.New("conversation: session not found") // ListQuery filters and paginates a session listing built from the projection. type ListQuery struct { - // AgentSlug, when set, restricts results to one agent's sessions. - AgentSlug *shared.Slug + // AgentCode, when set, restricts results to one agent's sessions. + AgentCode *shared.Code // Limit caps the number of rows returned; zero means the implementation's // default. Limit int diff --git a/packages/agenty-core/pkg/domain/conversation/session.go b/packages/agenty-core/pkg/domain/conversation/session.go index a6bc39a..1b2e297 100644 --- a/packages/agenty-core/pkg/domain/conversation/session.go +++ b/packages/agenty-core/pkg/domain/conversation/session.go @@ -20,7 +20,7 @@ var ( type Session struct { ID uuid.UUID `json:"id"` - AgentSlug shared.Slug `json:"agentSlug"` + AgentCode shared.Code `json:"agentCode"` Title *string `json:"title,omitempty"` Cwd *string `json:"cwd,omitempty"` CurrentModel *shared.ModelRef `json:"currentModel,omitempty"` @@ -44,11 +44,11 @@ type CompactionInput struct { At time.Time } -func StartSession(agentSlug shared.Slug, model shared.ModelRef, contextWindow int64, effort shared.ReasoningEffort, cwd *string) *Session { +func StartSession(agentCode shared.Code, model shared.ModelRef, contextWindow int64, effort shared.ReasoningEffort, cwd *string) *Session { s := &Session{Rounds: make([]Round, 0)} s.record(SessionStarted{ SessionID: shared.NewID(), - Agent: agentSlug, + Agent: agentCode, Model: model, ContextWindow: contextWindow, ReasoningEffort: effort, @@ -267,7 +267,7 @@ func (s *Session) apply(e shared.Event) { switch ev := e.(type) { case SessionStarted: s.ID = ev.SessionID - s.AgentSlug = ev.Agent + s.AgentCode = ev.Agent s.CurrentModel = &ev.Model s.ContextWindow = ev.ContextWindow s.CurrentReasoningEffort = ev.ReasoningEffort diff --git a/packages/agenty-core/pkg/domain/conversation/session_test.go b/packages/agenty-core/pkg/domain/conversation/session_test.go index b6ea9ac..4bf7c23 100644 --- a/packages/agenty-core/pkg/domain/conversation/session_test.go +++ b/packages/agenty-core/pkg/domain/conversation/session_test.go @@ -124,7 +124,7 @@ func TestSessionLifecycleAndReplay(t *testing.T) { t.Errorf("assistant message metadata = %+v", round.Messages[1]) } summary := replayed.Summary() - if summary.Title != "greeting" || summary.LastProviderSlug != "anthropic" || summary.LastModelSlug != "claude-opus-4" { + if summary.Title != "greeting" || summary.LastProviderCode != "anthropic" || summary.LastModelCode != "claude-opus-4" { t.Errorf("summary = %+v", summary) } } @@ -240,6 +240,10 @@ func TestSessionCompactionRetainsThreeUsersBeforeSummaryAndFiveAssistantsAfter(t assistantContent = Content{ ReasoningBlock{Reasoning: "private"}, ToolUseBlock{ID: "lookup", Name: "read_file", Input: shared.RawJSON(`{"path":"README.md"}`)}, + ApplyPatchCallBlock{ + CallID: "patch", Source: ApplyPatchSourceCustom, + Patch: "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch", + }, TextBlock{Text: "assistant-6"}, } } diff --git a/packages/agenty-core/pkg/domain/conversation/summary.go b/packages/agenty-core/pkg/domain/conversation/summary.go index 4af92a2..d21edaf 100644 --- a/packages/agenty-core/pkg/domain/conversation/summary.go +++ b/packages/agenty-core/pkg/domain/conversation/summary.go @@ -11,9 +11,9 @@ import ( type SessionSummary struct { ID uuid.UUID `json:"id"` Title string `json:"title"` - AgentSlug shared.Slug `json:"agentSlug"` - LastProviderSlug shared.Slug `json:"lastProviderSlug"` - LastModelSlug shared.ModelID `json:"lastModelSlug"` + AgentCode shared.Code `json:"agentCode"` + LastProviderCode shared.Code `json:"lastProviderCode"` + LastModelCode shared.ModelCode `json:"lastModelCode"` ContextWindow int64 `json:"contextWindow"` LastReasoningEffort shared.ReasoningEffort `json:"lastReasoningEffort"` CreatedAt time.Time `json:"createdAt"` @@ -23,7 +23,7 @@ type SessionSummary struct { func (s *Session) Summary() SessionSummary { sum := SessionSummary{ ID: s.ID, - AgentSlug: s.AgentSlug, + AgentCode: s.AgentCode, ContextWindow: s.ContextWindow, LastReasoningEffort: s.CurrentReasoningEffort, CreatedAt: s.CreatedAt, @@ -34,8 +34,8 @@ func (s *Session) Summary() SessionSummary { sum.Title = *s.Title } if s.CurrentModel != nil { - sum.LastProviderSlug = s.CurrentModel.ProviderSlug - sum.LastModelSlug = s.CurrentModel.ModelSlug + sum.LastProviderCode = s.CurrentModel.ProviderCode + sum.LastModelCode = s.CurrentModel.ModelCode } return sum diff --git a/packages/agenty-core/pkg/domain/shared/code.go b/packages/agenty-core/pkg/domain/shared/code.go new file mode 100644 index 0000000..38e8157 --- /dev/null +++ b/packages/agenty-core/pkg/domain/shared/code.go @@ -0,0 +1,29 @@ +package shared + +import ( + "fmt" + "regexp" +) + +var codePattern = regexp.MustCompile(`^[a-z]+[a-z0-9]*(?:[-._][a-z0-9]+)*$`) + +type Code string + +func NewCode(s string) (Code, error) { + if !codePattern.MatchString(s) { + return "", fmt.Errorf("shared: invalid code %q: must start with a lowercase letter and use only lowercase letters, digits, '-', '.' and '_'", s) + } + return Code(s), nil +} + +func (s Code) String() string { + return string(s) +} + +func (s Code) IsZero() bool { + return s == "" +} + +func (s Code) Valid() bool { + return codePattern.MatchString(string(s)) +} diff --git a/packages/agenty-core/pkg/domain/shared/slug_test.go b/packages/agenty-core/pkg/domain/shared/code_test.go similarity index 81% rename from packages/agenty-core/pkg/domain/shared/slug_test.go rename to packages/agenty-core/pkg/domain/shared/code_test.go index 27c6d93..7253ae0 100644 --- a/packages/agenty-core/pkg/domain/shared/slug_test.go +++ b/packages/agenty-core/pkg/domain/shared/code_test.go @@ -2,7 +2,7 @@ package shared import "testing" -func TestNewSlug(t *testing.T) { +func TestNewCode(t *testing.T) { t.Parallel() tests := []struct { @@ -28,18 +28,18 @@ func TestNewSlug(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - slug, err := NewSlug(tt.value) + code, err := NewCode(tt.value) if tt.valid { if err != nil { - t.Fatalf("NewSlug(%q): %v", tt.value, err) + t.Fatalf("NewCode(%q): %v", tt.value, err) } - if !slug.Valid() { - t.Errorf("Slug(%q).Valid() = false", tt.value) + if !code.Valid() { + t.Errorf("Code(%q).Valid() = false", tt.value) } return } if err == nil { - t.Errorf("NewSlug(%q) succeeded, want error", tt.value) + t.Errorf("NewCode(%q) succeeded, want error", tt.value) } }) } diff --git a/packages/agenty-core/pkg/domain/shared/misc.go b/packages/agenty-core/pkg/domain/shared/misc.go index 7f0215c..633315c 100644 --- a/packages/agenty-core/pkg/domain/shared/misc.go +++ b/packages/agenty-core/pkg/domain/shared/misc.go @@ -13,23 +13,23 @@ func NewID() uuid.UUID { type Metadata map[string]any type ModelRef struct { - ProviderSlug Slug `json:"providerSlug"` - ModelSlug ModelID `json:"modelSlug"` + ProviderCode Code `json:"providerCode"` + ModelCode ModelCode `json:"modelCode"` } -func NewModelRef(provider Slug, model ModelID) ModelRef { +func NewModelRef(provider Code, model ModelCode) ModelRef { return ModelRef{ - ProviderSlug: provider, - ModelSlug: model, + ProviderCode: provider, + ModelCode: model, } } func (r ModelRef) IsZero() bool { - return r.ProviderSlug.IsZero() && r.ModelSlug.IsZero() + return r.ProviderCode.IsZero() && r.ModelCode.IsZero() } func (r ModelRef) String() string { - return r.ProviderSlug.String() + "/" + r.ModelSlug.String() + return r.ProviderCode.String() + "/" + r.ModelCode.String() } type RawJSON = json.RawMessage diff --git a/packages/agenty-core/pkg/domain/shared/model_code.go b/packages/agenty-core/pkg/domain/shared/model_code.go new file mode 100644 index 0000000..24a06fa --- /dev/null +++ b/packages/agenty-core/pkg/domain/shared/model_code.go @@ -0,0 +1,37 @@ +package shared + +import ( + "fmt" + "unicode" + "unicode/utf8" +) + +// ModelCode identifies a provider model. It is kept as an opaque upstream +// identifier because compatible APIs may use path separators and other +// characters that are unsafe or ambiguous in a file name. +type ModelCode string + +func NewModelCode(value string) (ModelCode, error) { + if value == "" || !utf8.ValidString(value) { + return "", fmt.Errorf("shared: invalid model code %q: must be a non-empty valid UTF-8 value", value) + } + for _, r := range value { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return "", fmt.Errorf("shared: invalid model code %q: whitespace and control characters are not allowed", value) + } + } + return ModelCode(value), nil +} + +func (code ModelCode) String() string { + return string(code) +} + +func (code ModelCode) IsZero() bool { + return code == "" +} + +func (code ModelCode) Valid() bool { + _, err := NewModelCode(code.String()) + return err == nil +} diff --git a/packages/agenty-core/pkg/domain/shared/model_code_test.go b/packages/agenty-core/pkg/domain/shared/model_code_test.go new file mode 100644 index 0000000..27a3aa9 --- /dev/null +++ b/packages/agenty-core/pkg/domain/shared/model_code_test.go @@ -0,0 +1,42 @@ +package shared + +import "testing" + +func TestNewModelCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + valid bool + }{ + {name: "plain", value: "gpt-5-mini", valid: true}, + {name: "underscore", value: "o1_mini", valid: true}, + {name: "namespace", value: "openai/gpt-oss", valid: true}, + {name: "variant brackets", value: "model[thinking]", valid: true}, + {name: "all requested separators", value: `org\\model_name:v2`, valid: true}, + {name: "uppercase provider code", value: "GPT-5.6", valid: true}, + {name: "empty", value: "", valid: false}, + {name: "surrounding whitespace", value: " model ", valid: false}, + {name: "control character", value: "model\nname", valid: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + modelCode, err := NewModelCode(tt.value) + if tt.valid { + if err != nil { + t.Fatalf("NewModelCode(%q): %v", tt.value, err) + } + if !modelCode.Valid() { + t.Errorf("ModelCode(%q).Valid() = false", tt.value) + } + return + } + if err == nil { + t.Errorf("NewModelCode(%q) succeeded, want error", tt.value) + } + }) + } +} diff --git a/packages/agenty-core/pkg/domain/shared/model_id.go b/packages/agenty-core/pkg/domain/shared/model_id.go deleted file mode 100644 index 6032cba..0000000 --- a/packages/agenty-core/pkg/domain/shared/model_id.go +++ /dev/null @@ -1,31 +0,0 @@ -package shared - -import ( - "fmt" - "regexp" -) - -// ModelID identifies a provider model. Unlike Slug, it may contain provider -// namespace separators and model variant markers used by compatible APIs. -type ModelID string - -var modelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._\[\]-]*(?:/[a-z][a-z0-9._\[\]-]*)*$`) - -func NewModelID(value string) (ModelID, error) { - if !modelIDPattern.MatchString(value) { - return "", fmt.Errorf("shared: invalid model id %q: must start with a lowercase letter and use only lowercase letters, digits, '-', '.', '_', '/', '[' and ']'", value) - } - return ModelID(value), nil -} - -func (id ModelID) String() string { - return string(id) -} - -func (id ModelID) IsZero() bool { - return id == "" -} - -func (id ModelID) Valid() bool { - return modelIDPattern.MatchString(string(id)) -} diff --git a/packages/agenty-core/pkg/domain/shared/model_id_test.go b/packages/agenty-core/pkg/domain/shared/model_id_test.go deleted file mode 100644 index 03edaed..0000000 --- a/packages/agenty-core/pkg/domain/shared/model_id_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package shared - -import "testing" - -func TestNewModelID(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value string - valid bool - }{ - {name: "plain", value: "gpt-5-mini", valid: true}, - {name: "underscore", value: "o1_mini", valid: true}, - {name: "namespace", value: "openai/gpt-oss", valid: true}, - {name: "variant brackets", value: "model[thinking]", valid: true}, - {name: "all requested separators", value: "org/model_name[v2]", valid: true}, - {name: "empty", value: "", valid: false}, - {name: "leading digit", value: "4o-mini", valid: false}, - {name: "leading slash", value: "/model", valid: false}, - {name: "trailing slash", value: "org/model/", valid: false}, - {name: "whitespace", value: "model name", valid: false}, - {name: "uppercase", value: "Model", valid: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - modelID, err := NewModelID(tt.value) - if tt.valid { - if err != nil { - t.Fatalf("NewModelID(%q): %v", tt.value, err) - } - if !modelID.Valid() { - t.Errorf("ModelID(%q).Valid() = false", tt.value) - } - return - } - if err == nil { - t.Errorf("NewModelID(%q) succeeded, want error", tt.value) - } - }) - } -} diff --git a/packages/agenty-core/pkg/domain/shared/slug.go b/packages/agenty-core/pkg/domain/shared/slug.go deleted file mode 100644 index 33945a9..0000000 --- a/packages/agenty-core/pkg/domain/shared/slug.go +++ /dev/null @@ -1,29 +0,0 @@ -package shared - -import ( - "fmt" - "regexp" -) - -var slugPattern = regexp.MustCompile(`^[a-z]+[a-z0-9]*(?:[-._][a-z0-9]+)*$`) - -type Slug string - -func NewSlug(s string) (Slug, error) { - if !slugPattern.MatchString(s) { - return "", fmt.Errorf("shared: invalid slug %q: must start with a lowercase letter and use only lowercase letters, digits, '-', '.' and '_'", s) - } - return Slug(s), nil -} - -func (s Slug) String() string { - return string(s) -} - -func (s Slug) IsZero() bool { - return s == "" -} - -func (s Slug) Valid() bool { - return slugPattern.MatchString(string(s)) -} diff --git a/packages/agenty-core/pkg/infra/initialize/initialize_test.go b/packages/agenty-core/pkg/infra/initialize/initialize_test.go index 702a8e2..44fac59 100644 --- a/packages/agenty-core/pkg/infra/initialize/initialize_test.go +++ b/packages/agenty-core/pkg/infra/initialize/initialize_test.go @@ -50,7 +50,7 @@ func TestOpenRepositoriesEndToEnd(t *testing.T) { t.Fatal(err) } provider.AddModel(catalog.Model{ - Slug: mustModelID("claude-opus-4-8"), + Code: mustModelCode("claude-opus-4-8"), Name: "Claude Opus 4.8", ContextWindow: 200_000, MaxOutputTokens: 32_000, @@ -59,7 +59,7 @@ func TestOpenRepositoriesEndToEnd(t *testing.T) { t.Fatalf("Save provider: %v", err) } - modelRef := shared.NewModelRef(provider.Slug, mustModelID("claude-opus-4-8")) + modelRef := shared.NewModelRef(provider.Code, mustModelCode("claude-opus-4-8")) a, err := agent.New("coder", "Code Assistant") if err != nil { t.Fatal(err) @@ -71,14 +71,14 @@ func TestOpenRepositoriesEndToEnd(t *testing.T) { t.Fatalf("Save agent: %v", err) } - loadedProvider, err := repos.Catalog.Get(ctx, provider.Slug) + loadedProvider, err := repos.Catalog.Get(ctx, provider.Code) if err != nil { t.Fatalf("Get provider: %v", err) } if len(loadedProvider.Models) != 1 { t.Errorf("loaded %d models, want 1", len(loadedProvider.Models)) } - loadedAgent, err := repos.Agent.Get(ctx, a.Slug) + loadedAgent, err := repos.Agent.Get(ctx, a.Code) if err != nil { t.Fatalf("Get agent: %v", err) } @@ -91,7 +91,7 @@ func TestOpenRepositoriesEndToEnd(t *testing.T) { // Conversation flow: the application layer resolves the agent's default // configuration before constructing a session. - session := conversation.StartSession(loadedAgent.Slug, *loadedAgent.DefaultModel, loadedAgent.DefaultContextWindow, loadedAgent.DefaultReasoningEffort, nil) + session := conversation.StartSession(loadedAgent.Code, *loadedAgent.DefaultModel, loadedAgent.DefaultContextWindow, loadedAgent.DefaultReasoningEffort, nil) roundID, err := session.StartRound() if err != nil { t.Fatal(err) @@ -140,16 +140,16 @@ func TestOpenRepositoriesEndToEnd(t *testing.T) { } } -func mustSlug(s string) shared.Slug { - slug, err := shared.NewSlug(s) +func mustCode(s string) shared.Code { + code, err := shared.NewCode(s) if err != nil { panic(err) } - return slug + return code } -func mustModelID(s string) shared.ModelID { - id, err := shared.NewModelID(s) +func mustModelCode(s string) shared.ModelCode { + id, err := shared.NewModelCode(s) if err != nil { panic(err) } diff --git a/packages/agenty-core/pkg/infra/llm/anthropic.go b/packages/agenty-core/pkg/infra/llm/anthropic.go index 880f7db..aedc37b 100644 --- a/packages/agenty-core/pkg/infra/llm/anthropic.go +++ b/packages/agenty-core/pkg/infra/llm/anthropic.go @@ -8,6 +8,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" json "github.com/bytedance/sonic" + "github.com/masteryyh/agenty-core/pkg/agentloop" "github.com/masteryyh/agenty-core/pkg/domain/catalog" "github.com/masteryyh/agenty-core/pkg/domain/conversation" "github.com/masteryyh/agenty-core/pkg/domain/shared" @@ -126,7 +127,7 @@ func (caller *anthropicCaller) params(request modelRequest) (anthropic.MessageNe } params := anthropic.MessageNewParams{ - Model: anthropic.Model(caller.model.Slug.String()), + Model: anthropic.Model(caller.model.Code.String()), Messages: messages, MaxTokens: request.MaxOutputTokens, Tools: tools, @@ -151,6 +152,9 @@ func (caller *anthropicCaller) params(request modelRequest) (anthropic.MessageNe func anthropicTools(definitions []modelToolDefinition) ([]anthropic.ToolUnionParam, error) { tools := make([]anthropic.ToolUnionParam, 0, len(definitions)) for _, definition := range definitions { + if definition.Type == agentloop.ToolTypeApplyPatch { + continue + } tool, err := anthropicToolDefinition(definition) if err != nil { return nil, err @@ -232,6 +236,12 @@ func anthropicMessage(message conversation.Message) (anthropic.MessageParam, err } call := value.ToolUseBlock() content = append(content, anthropic.NewToolUseBlock(call.ID, call.Input, call.Name)) + case conversation.ApplyPatchCallBlock: + if message.Role != conversation.RoleAssistant { + return anthropic.MessageParam{}, unsupportedContent("Anthropic apply patch call requires assistant role") + } + call := value.ToolUseBlock() + content = append(content, anthropic.NewToolUseBlock(call.ID, call.Input, call.Name)) case conversation.ToolResultBlock: if message.Role != conversation.RoleUser { return anthropic.MessageParam{}, unsupportedContent("Anthropic tool result requires user role") diff --git a/packages/agenty-core/pkg/infra/llm/contract.go b/packages/agenty-core/pkg/infra/llm/contract.go index d9adf1e..7025fb3 100644 --- a/packages/agenty-core/pkg/infra/llm/contract.go +++ b/packages/agenty-core/pkg/infra/llm/contract.go @@ -64,7 +64,7 @@ func providerToolType(tool modelToolDefinition) (agentloop.ToolType, error) { return agentloop.ToolTypeFunction, nil } switch tool.Type { - case agentloop.ToolTypeFunction, agentloop.ToolTypeShell: + case agentloop.ToolTypeFunction, agentloop.ToolTypeShell, agentloop.ToolTypeApplyPatch: return tool.Type, nil default: return "", invalidRequest("tool %q has unsupported type %q", tool.Name, tool.Type) diff --git a/packages/agenty-core/pkg/infra/llm/convert.go b/packages/agenty-core/pkg/infra/llm/convert.go index 089abb5..4048674 100644 --- a/packages/agenty-core/pkg/infra/llm/convert.go +++ b/packages/agenty-core/pkg/infra/llm/convert.go @@ -50,11 +50,11 @@ func nativeReasoningEffort(model catalog.Model, effort shared.ReasoningEffort) ( switch len(matches) { case 0: - return "", invalidRequest("model %q does not support reasoning effort %q", model.Slug, effort) + return "", invalidRequest("model %q does not support reasoning effort %q", model.Code, effort) case 1: return matches[0], nil default: - return "", invalidRequest("model %q maps reasoning effort %q ambiguously to %s", model.Slug, effort, strings.Join(matches, ", ")) + return "", invalidRequest("model %q maps reasoning effort %q ambiguously to %s", model.Code, effort, strings.Join(matches, ", ")) } } diff --git a/packages/agenty-core/pkg/infra/llm/convert_test.go b/packages/agenty-core/pkg/infra/llm/convert_test.go index 683a940..cec22b1 100644 --- a/packages/agenty-core/pkg/infra/llm/convert_test.go +++ b/packages/agenty-core/pkg/infra/llm/convert_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "slices" + "strings" "testing" "github.com/anthropics/anthropic-sdk-go" @@ -167,7 +168,7 @@ func TestProviderRequestConversions(t *testing.T) { func modelWithReasoningNative(native string) catalog.Model { return catalog.Model{ - Slug: "test-model", + Code: "test-model", ReasoningEffortMapping: map[string]shared.ReasoningEffort{ native: shared.ReasoningHigh, }, @@ -299,6 +300,72 @@ func TestShellToolDefinitionConversions(t *testing.T) { } } +func TestApplyPatchToolRegistrations(t *testing.T) { + t.Parallel() + + definitions := []modelToolDefinition{ + testNamedTool("read_file"), + testNamedTool("write_file"), + testNamedTool("patch_file"), + testNamedTool("delete_file"), + testApplyPatchTool(), + } + + native, err := openAIResponsesTools(definitions, true) + if err != nil { + t.Fatal(err) + } + if len(native) != 2 || native[0].OfFunction == nil || native[0].OfFunction.Name != "read_file" || + native[1].OfApplyPatch == nil { + t.Fatalf("native Responses tools = %#v, want read_file and native apply_patch", native) + } + + compatible, err := openAIResponsesTools(definitions, false) + if err != nil { + t.Fatal(err) + } + if len(compatible) != 2 || compatible[0].OfFunction == nil || compatible[0].OfFunction.Name != "read_file" || + compatible[1].OfCustom == nil || compatible[1].OfCustom.Name != "apply_patch" { + t.Fatalf("compatible Responses tools = %#v, want read_file and custom apply_patch", compatible) + } + + chat, err := openAIChatTools(definitions) + if err != nil { + t.Fatal(err) + } + if names := openAIChatToolNames(chat); !slices.Equal(names, []string{ + "read_file", "write_file", "patch_file", "delete_file", + }) { + t.Errorf("OpenAI Chat tools = %q", names) + } + + anthropicDefinitions, err := anthropicTools(definitions) + if err != nil { + t.Fatal(err) + } + if len(anthropicDefinitions) != 4 { + t.Errorf("Anthropic tools = %d, want 4 original filesystem tools", len(anthropicDefinitions)) + } + + googleDefinitions, err := googleTools(definitions) + if err != nil { + t.Fatal(err) + } + if len(googleDefinitions) != 4 { + t.Errorf("Google tools = %#v, want 4 original filesystem tools", googleDefinitions) + } +} + +func openAIChatToolNames(tools []openai.ChatCompletionToolUnionParam) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + if tool.OfFunction != nil { + names = append(names, tool.OfFunction.Function.Name) + } + } + return names +} + func TestProviderToolRegistrationsRejectUnknownToolType(t *testing.T) { t.Parallel() @@ -434,6 +501,55 @@ func TestProviderResponseConversions(t *testing.T) { } }) + t.Run("OpenAI Responses native apply patch call", func(t *testing.T) { + t.Parallel() + + var sdkResponse responses.Response + mustUnmarshal(t, `{ + "id":"resp_patch","model":"gpt-test","status":"completed", + "output":[{ + "type":"apply_patch_call","id":"apc_1","call_id":"call_1","status":"completed", + "operation":{"type":"update_file","path":"main.go","diff":"@@\n-old\n+new"} + }], + "usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}} + }`, &sdkResponse) + + response, err := openAIResponsesResponse(&sdkResponse) + if err != nil { + t.Fatal(err) + } + assertResponse(t, response, modelStopReasonToolUse, 1, 7) + call, ok := response.Content[0].(conversation.ApplyPatchCallBlock) + if !ok || call.Source != conversation.ApplyPatchSourceNative || call.Operation == nil || + call.Operation.Type != conversation.ApplyPatchUpdateFile || call.Operation.Path != "main.go" { + t.Fatalf("apply patch call = %#v", response.Content[0]) + } + }) + + t.Run("OpenAI Responses custom apply patch call", func(t *testing.T) { + t.Parallel() + + const patch = "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch" + var sdkResponse responses.Response + mustUnmarshal(t, `{ + "id":"resp_patch","model":"gpt-test","status":"completed", + "output":[{ + "type":"custom_tool_call","id":"ctc_1","call_id":"call_1","name":"apply_patch", + "input":"*** Begin Patch\n*** Delete File: old.txt\n*** End Patch","status":"completed" + }], + "usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}} + }`, &sdkResponse) + + response, err := openAIResponsesResponse(&sdkResponse) + if err != nil { + t.Fatal(err) + } + call, ok := response.Content[0].(conversation.ApplyPatchCallBlock) + if !ok || call.Source != conversation.ApplyPatchSourceCustom || call.Patch != patch { + t.Fatalf("custom apply patch call = %#v", response.Content[0]) + } + }) + t.Run("OpenAI Chat Completions", func(t *testing.T) { t.Parallel() @@ -693,6 +809,88 @@ func TestShellMessageConversionsAcrossProviders(t *testing.T) { } } +func TestApplyPatchMessageConversionsAcrossProviders(t *testing.T) { + t.Parallel() + + operation := conversation.ApplyPatchOperation{ + Type: conversation.ApplyPatchUpdateFile, + Path: "notes.txt", + Diff: "@@\n-old\n+new", + } + call := conversation.ApplyPatchCallBlock{ + ID: "apc_1", CallID: "call_1", Source: conversation.ApplyPatchSourceNative, + Operation: &operation, + } + assistant := conversation.Message{ + Role: conversation.RoleAssistant, Content: conversation.Content{call}, + } + result := conversation.Message{ + Role: conversation.RoleUser, + Content: conversation.Content{conversation.ToolResultBlock{ + ToolUseID: "call_1", Content: conversation.Text(`{"operations":[{"type":"update_file","path":"notes.txt"}]}`), + }}, + } + + nativeItems, err := openAIResponsesMessages([]conversation.Message{assistant, result}, true) + if err != nil { + t.Fatal(err) + } + if len(nativeItems) != 2 || nativeItems[0].OfApplyPatchCall == nil || + nativeItems[1].OfApplyPatchCallOutput == nil || nativeItems[1].OfApplyPatchCallOutput.Status != "completed" { + t.Fatalf("native Responses history = %#v", nativeItems) + } + + compatibleItems, err := openAIResponsesMessages([]conversation.Message{assistant, result}, false) + if err != nil { + t.Fatal(err) + } + if len(compatibleItems) != 2 || compatibleItems[0].OfCustomToolCall == nil || + compatibleItems[1].OfCustomToolCallOutput == nil { + t.Fatalf("compatible Responses history = %#v", compatibleItems) + } + wantPatch := "*** Begin Patch\n*** Update File: notes.txt\n@@\n-old\n+new\n*** End Patch" + if compatibleItems[0].OfCustomToolCall.Input != wantPatch { + t.Errorf("compatible patch = %q, want %q", compatibleItems[0].OfCustomToolCall.Input, wantPatch) + } + + customCall := conversation.ApplyPatchCallBlock{ + ID: "ctc_1", CallID: "call_2", Source: conversation.ApplyPatchSourceCustom, + Patch: "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch", + } + customItems, err := openAIResponsesMessages([]conversation.Message{ + {Role: conversation.RoleAssistant, Content: conversation.Content{customCall}}, + {Role: conversation.RoleUser, Content: conversation.Content{conversation.ToolResultBlock{ + ToolUseID: "call_2", Content: conversation.Text(`{"operations":[{"type":"delete_file","path":"old.txt"}]}`), + }}}, + }, true) + if err != nil { + t.Fatal(err) + } + if len(customItems) != 2 || customItems[0].OfCustomToolCall == nil || + customItems[1].OfCustomToolCallOutput == nil { + t.Fatalf("custom Responses history = %#v", customItems) + } + + chatMessages, err := openAIChatMessages(assistant) + if err != nil || len(chatMessages) != 1 || len(chatMessages[0].OfAssistant.ToolCalls) != 1 || + chatMessages[0].OfAssistant.ToolCalls[0].OfFunction.Function.Name != "apply_patch" { + t.Fatalf("Chat apply patch history = %#v, err = %v", chatMessages, err) + } + anthropicMessage, err := anthropicMessage(assistant) + if err != nil || len(anthropicMessage.Content) != 1 || + anthropicMessage.Content[0].OfToolUse == nil || anthropicMessage.Content[0].OfToolUse.Name != "apply_patch" { + t.Fatalf("Anthropic apply patch history = %#v, err = %v", anthropicMessage, err) + } + googleMessage, err := googleMessage(assistant, nil) + if err != nil || len(googleMessage.Parts) != 1 || + googleMessage.Parts[0].FunctionCall == nil || googleMessage.Parts[0].FunctionCall.Name != "apply_patch" { + t.Fatalf("Google apply patch history = %#v, err = %v", googleMessage, err) + } + if name := googleToolNames([]conversation.Message{assistant})["call_1"]; name != "apply_patch" { + t.Errorf("Google apply patch tool name = %q", name) + } +} + func TestOpenAIResponsesShellOutputUsesPersistedSource(t *testing.T) { t.Parallel() @@ -792,6 +990,42 @@ func TestStreamEventConversions(t *testing.T) { } } +func TestOpenAIResponsesCustomApplyPatchStreamUsesCompletedFreeformInput(t *testing.T) { + t.Parallel() + + called := false + err := emitOpenAIResponsesEvent(func(modelStreamEvent) error { + called = true + return nil + }, openAIResponsesStreamEvent{ + Type: "response.custom_tool_call_input.delta", OutputIndex: 1, + Delta: "*** Begin Patch", + }) + if err != nil { + t.Fatal(err) + } + if called { + t.Fatal("custom freeform delta was emitted as a JSON tool input delta") + } + + done := openAIResponsesStreamEvent{Type: "response.output_item.done", OutputIndex: 1} + done.Item.Type = "custom_tool_call" + done.Item.CallID = "call_1" + done.Item.Name = "apply_patch" + done.Item.Input = "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch" + var got modelStreamEvent + if err := emitOpenAIResponsesEvent(func(event modelStreamEvent) error { + got = event + return nil + }, done); err != nil { + t.Fatal(err) + } + if got.Type != modelStreamEventToolUseDone || got.ToolUseID != "call_1" || + !strings.Contains(string(got.ToolInput), `"patch":"*** Begin Patch`) { + t.Errorf("completed custom apply patch event = %#v", got) + } +} + func TestNewCallerValidation(t *testing.T) { t.Parallel() @@ -803,7 +1037,7 @@ func TestNewCallerValidation(t *testing.T) { }{ { name: "missing API key", - provider: catalog.Provider{Slug: "openai", Type: catalog.APIOpenAI}, + provider: catalog.Provider{Code: "openai", Type: catalog.APIOpenAI}, want: ErrInvalidRequest, }, } @@ -819,7 +1053,7 @@ func TestNewCallerValidation(t *testing.T) { } } -func TestNewCallerConfiguresNativeOpenAIShellByProviderIdentity(t *testing.T) { +func TestNewCallerConfiguresNativeOpenAIResponsesToolsByProviderIdentity(t *testing.T) { t.Parallel() tests := []struct { @@ -830,14 +1064,14 @@ func TestNewCallerConfiguresNativeOpenAIShellByProviderIdentity(t *testing.T) { { name: "built-in OpenAI with SDK default URL", provider: catalog.Provider{ - Slug: "openai", Type: catalog.APIOpenAI, APIKey: "test-key", + Code: "openai", Type: catalog.APIOpenAI, APIKey: "test-key", }, want: true, }, { name: "built-in OpenAI with official URL", provider: catalog.Provider{ - Slug: "openai", Type: catalog.APIOpenAI, APIKey: "test-key", + Code: "openai", Type: catalog.APIOpenAI, APIKey: "test-key", BaseURL: "https://api.openai.com/v1/", }, want: true, @@ -845,21 +1079,21 @@ func TestNewCallerConfiguresNativeOpenAIShellByProviderIdentity(t *testing.T) { { name: "OpenRouter Responses compatibility", provider: catalog.Provider{ - Slug: "openrouter", Type: catalog.APIOpenAI, APIKey: "test-key", + Code: "openrouter", Type: catalog.APIOpenAI, APIKey: "test-key", BaseURL: "https://openrouter.ai/api/v1", }, }, { - name: "custom proxy using OpenAI slug", + name: "custom proxy using OpenAI code", provider: catalog.Provider{ - Slug: "openai", Type: catalog.APIOpenAI, APIKey: "test-key", + Code: "openai", Type: catalog.APIOpenAI, APIKey: "test-key", BaseURL: "https://proxy.example/v1", }, }, { name: "custom provider using official endpoint", provider: catalog.Provider{ - Slug: "custom-openai", Type: catalog.APIOpenAI, APIKey: "test-key", + Code: "custom-openai", Type: catalog.APIOpenAI, APIKey: "test-key", BaseURL: "https://api.openai.com/v1", }, }, @@ -876,8 +1110,8 @@ func TestNewCallerConfiguresNativeOpenAIShellByProviderIdentity(t *testing.T) { if !ok { t.Fatalf("NewCaller() = %T, want *openAIResponsesCaller", caller) } - if responsesCaller.nativeShell != tt.want { - t.Errorf("nativeShell = %v, want %v", responsesCaller.nativeShell, tt.want) + if responsesCaller.nativeOpenAI != tt.want { + t.Errorf("nativeOpenAI = %v, want %v", responsesCaller.nativeOpenAI, tt.want) } }) } @@ -885,7 +1119,7 @@ func TestNewCallerConfiguresNativeOpenAIShellByProviderIdentity(t *testing.T) { func testModel() catalog.Model { return catalog.Model{ - Slug: "test-model", + Code: "test-model", ReasoningEffortMapping: map[string]shared.ReasoningEffort{ "low": shared.ReasoningLow, "HIGH": shared.ReasoningHigh, }, @@ -931,6 +1165,18 @@ func testShellTool() modelToolDefinition { } } +func testApplyPatchTool() modelToolDefinition { + tool := testNamedTool("apply_patch") + tool.Type = agentloop.ToolTypeApplyPatch + return tool +} + +func testNamedTool(name string) modelToolDefinition { + tool := testTool() + tool.Name = name + return tool +} + func assertToolSchemaMap(t *testing.T, schema map[string]any) { t.Helper() if schema["type"] != "object" { diff --git a/packages/agenty-core/pkg/infra/llm/factory.go b/packages/agenty-core/pkg/infra/llm/factory.go index 82c6b65..0c0c286 100644 --- a/packages/agenty-core/pkg/infra/llm/factory.go +++ b/packages/agenty-core/pkg/infra/llm/factory.go @@ -48,19 +48,19 @@ func NewCaller( } if strings.TrimSpace(provider.APIKey) == "" { - return nil, invalidRequest("provider %q has no API key", provider.Slug) + return nil, invalidRequest("provider %q has no API key", provider.Code) } - if model.Slug.IsZero() { - return nil, invalidRequest("model slug must not be empty") + if model.Code.IsZero() { + return nil, invalidRequest("model code must not be empty") } switch provider.Type { case catalog.APIOpenAI: client := newOpenAIClient(provider, config) return &openAIResponsesCaller{ - client: &client, - model: model, - nativeShell: nativeOpenAIShellProvider(provider), + client: &client, + model: model, + nativeOpenAI: nativeOpenAIResponsesProvider(provider), }, nil case catalog.APIOpenAICompletions: client := newOpenAIClient(provider, config) @@ -79,8 +79,8 @@ func NewCaller( } } -func nativeOpenAIShellProvider(provider catalog.Provider) bool { - if provider.Slug.String() != "openai" { +func nativeOpenAIResponsesProvider(provider catalog.Provider) bool { + if provider.Code.String() != "openai" { return false } baseURL := strings.TrimSpace(provider.BaseURL) diff --git a/packages/agenty-core/pkg/infra/llm/google.go b/packages/agenty-core/pkg/infra/llm/google.go index 6d9ccb0..b5d2684 100644 --- a/packages/agenty-core/pkg/infra/llm/google.go +++ b/packages/agenty-core/pkg/infra/llm/google.go @@ -9,6 +9,7 @@ import ( "google.golang.org/genai" + "github.com/masteryyh/agenty-core/pkg/agentloop" "github.com/masteryyh/agenty-core/pkg/domain/catalog" "github.com/masteryyh/agenty-core/pkg/domain/conversation" ) @@ -24,7 +25,7 @@ func (caller *googleCaller) Invoke(ctx context.Context, request modelRequest) (* return nil, err } - result, err := caller.client.Models.GenerateContent(ctx, caller.model.Slug.String(), contents, config) + result, err := caller.client.Models.GenerateContent(ctx, caller.model.Code.String(), contents, config) if err != nil { return nil, fmt.Errorf("llm: invoke Google GenAI SDK: %w", err) } @@ -45,7 +46,7 @@ func (caller *googleCaller) Stream( merged := &genai.GenerateContentResponse{} for chunk, streamErr := range caller.client.Models.GenerateContentStream( ctx, - caller.model.Slug.String(), + caller.model.Code.String(), contents, config, ) { @@ -137,6 +138,9 @@ func (caller *googleCaller) params(request modelRequest) ([]*genai.Content, *gen func googleTools(definitions []modelToolDefinition) ([]*genai.FunctionDeclaration, error) { tools := make([]*genai.FunctionDeclaration, 0, len(definitions)) for _, definition := range definitions { + if definition.Type == agentloop.ToolTypeApplyPatch { + continue + } tool, err := googleToolDefinition(definition) if err != nil { return nil, err @@ -171,6 +175,8 @@ func googleToolNames(messages []conversation.Message) map[string]string { names[tool.ID] = tool.Name case conversation.ShellCallBlock: names[tool.CallID] = "shell" + case conversation.ApplyPatchCallBlock: + names[tool.CallID] = "apply_patch" } } } @@ -241,6 +247,18 @@ func googleMessage(message conversation.Message, toolNames map[string]string) (* part := genai.NewPartFromFunctionCall(call.Name, input) part.FunctionCall.ID = call.ID parts = append(parts, part) + case conversation.ApplyPatchCallBlock: + if message.Role != conversation.RoleAssistant { + return nil, unsupportedContent("Google apply patch call requires assistant role") + } + call := value.ToolUseBlock() + input, err := rawObject(call.Input, "tool input") + if err != nil { + return nil, err + } + part := genai.NewPartFromFunctionCall(call.Name, input) + part.FunctionCall.ID = call.ID + parts = append(parts, part) case conversation.ToolResultBlock: if message.Role != conversation.RoleUser { return nil, unsupportedContent("Google function response requires user role") diff --git a/packages/agenty-core/pkg/infra/llm/live_integration_test.go b/packages/agenty-core/pkg/infra/llm/live_integration_test.go index 2770dea..14f6b8a 100644 --- a/packages/agenty-core/pkg/infra/llm/live_integration_test.go +++ b/packages/agenty-core/pkg/infra/llm/live_integration_test.go @@ -17,29 +17,29 @@ func TestLiveProviders(t *testing.T) { tests := []struct { name string apiType catalog.APIType - providerSlug shared.Slug + providerCode shared.Code keyEnv string baseURLEnv string modelEnv string - defaultModel shared.ModelID + defaultModel shared.ModelCode }{ { - name: "OpenAI Responses", apiType: catalog.APIOpenAI, providerSlug: "openai", + name: "OpenAI Responses", apiType: catalog.APIOpenAI, providerCode: "openai", keyEnv: "OPENAI_API_KEY", baseURLEnv: "OPENAI_BASE_URL", modelEnv: "OPENAI_RESPONSES_MODEL", defaultModel: "gpt-5-mini", }, { - name: "OpenAI Chat Completions", apiType: catalog.APIOpenAICompletions, providerSlug: "openai-completions", + name: "OpenAI Chat Completions", apiType: catalog.APIOpenAICompletions, providerCode: "openai-completions", keyEnv: "OPENAI_API_KEY", baseURLEnv: "OPENAI_BASE_URL", modelEnv: "OPENAI_CHAT_MODEL", defaultModel: "gpt-4.1-mini", }, { - name: "Anthropic Messages", apiType: catalog.APIAnthropic, providerSlug: "anthropic", + name: "Anthropic Messages", apiType: catalog.APIAnthropic, providerCode: "anthropic", keyEnv: "ANTHROPIC_API_KEY", baseURLEnv: "ANTHROPIC_BASE_URL", modelEnv: "ANTHROPIC_MODEL", defaultModel: "claude-haiku-4-5", }, { - name: "Google GenAI", apiType: catalog.APIGemini, providerSlug: "google", + name: "Google GenAI", apiType: catalog.APIGemini, providerCode: "google", keyEnv: "GEMINI_API_KEY", baseURLEnv: "GEMINI_BASE_URL", modelEnv: "GEMINI_MODEL", defaultModel: "gemini-2.5-flash", }, @@ -51,15 +51,15 @@ func TestLiveProviders(t *testing.T) { t.Skipf("%s is not set; skipping live %s integration test", tt.keyEnv, tt.name) } - modelSlug := tt.defaultModel + modelCode := tt.defaultModel if configured := os.Getenv(tt.modelEnv); configured != "" { - modelSlug = shared.ModelID(configured) + modelCode = shared.ModelCode(configured) } provider := catalog.Provider{ - Slug: tt.providerSlug, Type: tt.apiType, + Code: tt.providerCode, Type: tt.apiType, APIKey: apiKey, BaseURL: os.Getenv(tt.baseURLEnv), } - model := catalog.Model{Slug: modelSlug} + model := catalog.Model{Code: modelCode} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/packages/agenty-core/pkg/infra/llm/openai_chat.go b/packages/agenty-core/pkg/infra/llm/openai_chat.go index a18e9f7..60b9196 100644 --- a/packages/agenty-core/pkg/infra/llm/openai_chat.go +++ b/packages/agenty-core/pkg/infra/llm/openai_chat.go @@ -8,6 +8,7 @@ import ( "github.com/openai/openai-go/v3" openaishared "github.com/openai/openai-go/v3/shared" + "github.com/masteryyh/agenty-core/pkg/agentloop" "github.com/masteryyh/agenty-core/pkg/domain/catalog" "github.com/masteryyh/agenty-core/pkg/domain/conversation" "github.com/masteryyh/agenty-core/pkg/domain/shared" @@ -141,7 +142,7 @@ func (caller *openAIChatCaller) params(request modelRequest) (openai.ChatComplet } params := openai.ChatCompletionNewParams{ - Model: openaishared.ChatModel(caller.model.Slug.String()), + Model: openaishared.ChatModel(caller.model.Code.String()), Messages: messages, MaxCompletionTokens: openai.Int(request.MaxOutputTokens), StreamOptions: openai.ChatCompletionStreamOptionsParam{ @@ -159,6 +160,9 @@ func (caller *openAIChatCaller) params(request modelRequest) (openai.ChatComplet func openAIChatTools(definitions []modelToolDefinition) ([]openai.ChatCompletionToolUnionParam, error) { tools := make([]openai.ChatCompletionToolUnionParam, 0, len(definitions)) for _, definition := range definitions { + if definition.Type == agentloop.ToolTypeApplyPatch { + continue + } tool, err := openAIChatToolDefinition(definition) if err != nil { return nil, err @@ -250,6 +254,16 @@ func openAIChatMessages(message conversation.Message) ([]openai.ChatCompletionMe }, }, }) + case conversation.ApplyPatchCallBlock: + call := value.ToolUseBlock() + toolCalls = append(toolCalls, openai.ChatCompletionMessageToolCallUnionParam{ + OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{ + ID: call.ID, + Function: openai.ChatCompletionMessageFunctionToolCallFunctionParam{ + Name: call.Name, Arguments: string(call.Input), + }, + }, + }) default: return nil, unsupportedContent("OpenAI Chat assistant message cannot contain %q", block.BlockType()) } diff --git a/packages/agenty-core/pkg/infra/llm/openai_responses.go b/packages/agenty-core/pkg/infra/llm/openai_responses.go index 2e9c340..f581c3d 100644 --- a/packages/agenty-core/pkg/infra/llm/openai_responses.go +++ b/packages/agenty-core/pkg/infra/llm/openai_responses.go @@ -17,9 +17,9 @@ import ( ) type openAIResponsesCaller struct { - client *openai.Client - model catalog.Model - nativeShell bool + client *openai.Client + model catalog.Model + nativeOpenAI bool } func (caller *openAIResponsesCaller) Invoke(ctx context.Context, request modelRequest) (*modelResponse, error) { @@ -80,6 +80,18 @@ func (caller *openAIResponsesCaller) Stream( Type: modelStreamEventToolUseStart, Index: int(event.OutputIndex), ToolUseID: item.CallID, ToolName: "shell", }) + case responses.ResponseApplyPatchToolCall: + err = emit(handler, modelStreamEvent{ + Type: modelStreamEventToolUseStart, Index: int(event.OutputIndex), + ToolUseID: item.CallID, ToolName: "apply_patch", + }) + case responses.ResponseCustomToolCall: + if item.Name == "apply_patch" { + err = emit(handler, modelStreamEvent{ + Type: modelStreamEventToolUseStart, Index: int(event.OutputIndex), + ToolUseID: item.CallID, ToolName: item.Name, + }) + } } case responses.ResponseOutputItemDoneEvent: switch item := event.Item.AsAny().(type) { @@ -98,6 +110,31 @@ func (caller *openAIResponsesCaller) Stream( Type: modelStreamEventToolUseDone, Index: int(event.OutputIndex), ToolUseID: item.CallID, ToolName: "shell", ToolInput: input, }) + case responses.ResponseApplyPatchToolCall: + operation, operationErr := openAIApplyPatchOperation(item.Operation) + if operationErr != nil { + err = operationErr + break + } + input := conversation.ApplyPatchCallBlock{ + CallID: item.CallID, Source: conversation.ApplyPatchSourceNative, + Operation: &operation, + }.ToolUseBlock().Input + err = emit(handler, modelStreamEvent{ + Type: modelStreamEventToolUseDone, Index: int(event.OutputIndex), + ToolUseID: item.CallID, ToolName: "apply_patch", ToolInput: input, + }) + case responses.ResponseCustomToolCall: + if item.Name == "apply_patch" { + input := conversation.ApplyPatchCallBlock{ + CallID: item.CallID, Source: conversation.ApplyPatchSourceCustom, + Patch: item.Input, + }.ToolUseBlock().Input + err = emit(handler, modelStreamEvent{ + Type: modelStreamEventToolUseDone, Index: int(event.OutputIndex), + ToolUseID: item.CallID, ToolName: item.Name, ToolInput: input, + }) + } } case responses.ResponseCompletedEvent: final, err = openAIResponsesResponse(&event.Response) @@ -133,18 +170,18 @@ func (caller *openAIResponsesCaller) params(request modelRequest) (responses.Res return responses.ResponseNewParams{}, err } - input, err := openAIResponsesMessages(request.Messages, caller.nativeShell) + input, err := openAIResponsesMessages(request.Messages, caller.nativeOpenAI) if err != nil { return responses.ResponseNewParams{}, err } - tools, err := openAIResponsesTools(request.Tools, caller.nativeShell) + tools, err := openAIResponsesTools(request.Tools, caller.nativeOpenAI) if err != nil { return responses.ResponseNewParams{}, err } params := responses.ResponseNewParams{ - Model: openaishared.ResponsesModel(caller.model.Slug.String()), + Model: openaishared.ResponsesModel(caller.model.Code.String()), Input: responses.ResponseNewParamsInputUnion{OfInputItemList: input}, MaxOutputTokens: openai.Int(request.MaxOutputTokens), Store: openai.Bool(false), @@ -163,10 +200,13 @@ func (caller *openAIResponsesCaller) params(request modelRequest) (responses.Res return params, nil } -func openAIResponsesTools(definitions []modelToolDefinition, nativeShell bool) ([]responses.ToolUnionParam, error) { +func openAIResponsesTools(definitions []modelToolDefinition, nativeOpenAI bool) ([]responses.ToolUnionParam, error) { tools := make([]responses.ToolUnionParam, 0, len(definitions)) for _, definition := range definitions { - tool, err := openAIResponsesToolDefinition(definition, nativeShell) + if isReplacedFileTool(definition.Name) { + continue + } + tool, err := openAIResponsesToolDefinition(definition, nativeOpenAI) if err != nil { return nil, err } @@ -177,9 +217,9 @@ func openAIResponsesTools(definitions []modelToolDefinition, nativeShell bool) ( func openAIResponsesMessages( messages []conversation.Message, - nativeShell bool, + nativeOpenAI bool, ) (responses.ResponseInputParam, error) { - legacyNativeCallIDs := legacyNativeShellCallIDs(messages) + callSources := openAIResponsesCallSources(messages) input := make(responses.ResponseInputParam, 0, len(messages)) for index, message := range messages { if message.Role == conversation.RoleSystem { @@ -187,8 +227,8 @@ func openAIResponsesMessages( } items, err := openAIResponsesMessageWithNativeCallIDs( message, - nativeShell, - legacyNativeCallIDs, + nativeOpenAI, + callSources, ) if err != nil { return nil, fmt.Errorf("llm: convert OpenAI Responses message %d: %w", index, err) @@ -198,31 +238,62 @@ func openAIResponsesMessages( return input, nil } -func legacyNativeShellCallIDs(messages []conversation.Message) map[string]struct{} { - callIDs := make(map[string]struct{}) +type openAIResponsesCallSource struct { + nativeShell bool + applyPatchSource conversation.ApplyPatchCallSource +} + +func openAIResponsesCallSources(messages []conversation.Message) map[string]openAIResponsesCallSource { + sources := make(map[string]openAIResponsesCallSource) for _, message := range messages { for _, block := range message.Content { - call, ok := block.(conversation.ShellCallBlock) - if ok && call.CallID != "" { - callIDs[call.CallID] = struct{}{} + switch call := block.(type) { + case conversation.ShellCallBlock: + if call.CallID != "" { + sources[call.CallID] = openAIResponsesCallSource{nativeShell: true} + } + case conversation.ApplyPatchCallBlock: + if call.CallID != "" { + sources[call.CallID] = openAIResponsesCallSource{ + applyPatchSource: call.Source, + } + } } } } - return callIDs + return sources +} + +func isReplacedFileTool(name string) bool { + switch name { + case "write_file", "patch_file", "delete_file": + return true + default: + return false + } } -func openAIResponsesToolDefinition(tool modelToolDefinition, nativeShell bool) (responses.ToolUnionParam, error) { +func openAIResponsesToolDefinition(tool modelToolDefinition, nativeOpenAI bool) (responses.ToolUnionParam, error) { toolType, err := providerToolType(tool) if err != nil { return responses.ToolUnionParam{}, err } - if toolType == agentloop.ToolTypeShell && nativeShell { + if toolType == agentloop.ToolTypeShell && nativeOpenAI { return responses.ToolUnionParam{OfShell: &responses.FunctionShellToolParam{ Environment: responses.FunctionShellToolEnvironmentUnionParam{ OfLocal: &responses.LocalEnvironmentParam{}, }, }}, nil } + if toolType == agentloop.ToolTypeApplyPatch { + if nativeOpenAI { + return responses.ToolUnionParam{OfApplyPatch: &responses.ApplyPatchToolParam{}}, nil + } + return responses.ToolUnionParam{OfCustom: &responses.CustomToolParam{ + Name: "apply_patch", + Description: openai.String(tool.Description), + }}, nil + } schema, err := toolSchemaMap(tool.InputSchema) if err != nil { @@ -239,14 +310,14 @@ func openAIResponsesToolDefinition(tool modelToolDefinition, nativeShell bool) ( return responses.ToolUnionParam{OfFunction: &converted}, nil } -func openAIResponsesMessage(message conversation.Message, nativeShell bool) (responses.ResponseInputParam, error) { - return openAIResponsesMessageWithNativeCallIDs(message, nativeShell, nil) +func openAIResponsesMessage(message conversation.Message, nativeOpenAI bool) (responses.ResponseInputParam, error) { + return openAIResponsesMessageWithNativeCallIDs(message, nativeOpenAI, nil) } func openAIResponsesMessageWithNativeCallIDs( message conversation.Message, - nativeShell bool, - legacyNativeCallIDs map[string]struct{}, + nativeOpenAI bool, + callSources map[string]openAIResponsesCallSource, ) (responses.ResponseInputParam, error) { role := responses.EasyInputMessageRole(message.Role) content := make(responses.ResponseInputMessageContentListParam, 0, len(message.Content)) @@ -301,7 +372,7 @@ func openAIResponsesMessageWithNativeCallIDs( return nil, unsupportedContent("OpenAI Responses shell call requires assistant role") } flush() - if !nativeShell { + if !nativeOpenAI { call := value.ToolUseBlock() if _, err := rawObject(call.Input, "tool input"); err != nil { return nil, err @@ -324,15 +395,50 @@ func openAIResponsesMessageWithNativeCallIDs( } item.OfShellCall.Environment.OfLocal = &responses.LocalEnvironmentParam{} items = append(items, item) + case conversation.ApplyPatchCallBlock: + if message.Role != conversation.RoleAssistant { + return nil, unsupportedContent("OpenAI Responses apply patch call requires assistant role") + } + flush() + item, err := openAIResponsesApplyPatchCall(value, nativeOpenAI) + if err != nil { + return nil, err + } + items = append(items, item) case conversation.ToolResultBlock: flush() + if source := callSources[value.ToolUseID].applyPatchSource; source != "" { + output, err := textContent(value.Content) + if err != nil { + return nil, err + } + if source == conversation.ApplyPatchSourceNative && nativeOpenAI { + status := "completed" + if value.IsError { + status = "failed" + } + item := responses.ResponseInputItemParamOfApplyPatchCallOutput( + value.ToolUseID, + status, + ) + if output != "" { + item.OfApplyPatchCallOutput.Output = openai.String(output) + } + items = append(items, item) + } else { + items = append(items, responses.ResponseInputItemParamOfCustomToolCallOutput( + value.ToolUseID, + output, + )) + } + continue + } useNativeShellOutput := false if output, ok := shellCallOutput(value.Content); ok { if output.OpenAINative != nil { - useNativeShellOutput = nativeShell && *output.OpenAINative + useNativeShellOutput = nativeOpenAI && *output.OpenAINative } else { - _, useNativeShellOutput = legacyNativeCallIDs[value.ToolUseID] - useNativeShellOutput = nativeShell && useNativeShellOutput + useNativeShellOutput = nativeOpenAI && callSources[value.ToolUseID].nativeShell } } if useNativeShellOutput { @@ -369,6 +475,143 @@ func shellCallOutput(content conversation.Content) (conversation.ShellCallOutput return output, ok } +func openAIResponsesApplyPatchCall( + call conversation.ApplyPatchCallBlock, + nativeOpenAI bool, +) (responses.ResponseInputItemUnionParam, error) { + if call.CallID == "" { + return responses.ResponseInputItemUnionParam{}, invalidRequest("apply patch call ID is required") + } + + if call.Source == conversation.ApplyPatchSourceCustom { + if call.Patch == "" { + return responses.ResponseInputItemUnionParam{}, invalidRequest("custom apply patch call has no patch") + } + item := responses.ResponseInputItemParamOfCustomToolCall(call.CallID, call.Patch, "apply_patch") + if call.ID != "" { + item.OfCustomToolCall.ID = openai.String(call.ID) + } + return item, nil + } + if call.Source != conversation.ApplyPatchSourceNative || call.Operation == nil { + return responses.ResponseInputItemUnionParam{}, invalidRequest("native apply patch call has no operation") + } + if !nativeOpenAI { + patch, err := applyPatchOperationEnvelope(*call.Operation) + if err != nil { + return responses.ResponseInputItemUnionParam{}, err + } + item := responses.ResponseInputItemParamOfCustomToolCall(call.CallID, patch, "apply_patch") + if call.ID != "" { + item.OfCustomToolCall.ID = openai.String(call.ID) + } + return item, nil + } + + operation := *call.Operation + var item responses.ResponseInputItemUnionParam + switch operation.Type { + case conversation.ApplyPatchCreateFile: + item = responses.ResponseInputItemParamOfApplyPatchCall( + call.CallID, + responses.ResponseInputItemApplyPatchCallOperationCreateFileParam{ + Path: operation.Path, + Diff: operation.Diff, + }, + "completed", + ) + case conversation.ApplyPatchDeleteFile: + item = responses.ResponseInputItemParamOfApplyPatchCall( + call.CallID, + responses.ResponseInputItemApplyPatchCallOperationDeleteFileParam{Path: operation.Path}, + "completed", + ) + case conversation.ApplyPatchUpdateFile: + if operation.MoveTo != "" { + return responses.ResponseInputItemUnionParam{}, invalidRequest( + "native apply patch operation cannot move files", + ) + } + item = responses.ResponseInputItemParamOfApplyPatchCall( + call.CallID, + responses.ResponseInputItemApplyPatchCallOperationUpdateFileParam{ + Path: operation.Path, + Diff: operation.Diff, + }, + "completed", + ) + default: + return responses.ResponseInputItemUnionParam{}, invalidRequest( + "apply patch operation has unknown type %q", + operation.Type, + ) + } + if call.ID != "" { + item.OfApplyPatchCall.ID = openai.String(call.ID) + } + return item, nil +} + +func applyPatchOperationEnvelope(operation conversation.ApplyPatchOperation) (string, error) { + var header string + switch operation.Type { + case conversation.ApplyPatchCreateFile: + header = "*** Add File: " + operation.Path + case conversation.ApplyPatchDeleteFile: + header = "*** Delete File: " + operation.Path + case conversation.ApplyPatchUpdateFile: + header = "*** Update File: " + operation.Path + default: + return "", invalidRequest("apply patch operation has unknown type %q", operation.Type) + } + if operation.Path == "" { + return "", invalidRequest("apply patch operation path is required") + } + + var patch strings.Builder + patch.WriteString("*** Begin Patch\n") + patch.WriteString(header) + patch.WriteByte('\n') + if operation.MoveTo != "" { + if operation.Type != conversation.ApplyPatchUpdateFile { + return "", invalidRequest("only update operations can move files") + } + patch.WriteString("*** Move to: ") + patch.WriteString(operation.MoveTo) + patch.WriteByte('\n') + } + patch.WriteString(operation.Diff) + if operation.Diff != "" && !strings.HasSuffix(operation.Diff, "\n") { + patch.WriteByte('\n') + } + patch.WriteString("*** End Patch") + return patch.String(), nil +} + +func openAIApplyPatchOperation( + operation responses.ResponseApplyPatchToolCallOperationUnion, +) (conversation.ApplyPatchOperation, error) { + converted := conversation.ApplyPatchOperation{ + Type: conversation.ApplyPatchOperationType(operation.Type), + Path: operation.Path, + Diff: operation.Diff, + } + if converted.Path == "" { + return conversation.ApplyPatchOperation{}, invalidRequest("apply patch operation path is required") + } + switch converted.Type { + case conversation.ApplyPatchCreateFile, conversation.ApplyPatchUpdateFile: + case conversation.ApplyPatchDeleteFile: + converted.Diff = "" + default: + return conversation.ApplyPatchOperation{}, invalidRequest( + "apply patch operation has unknown type %q", + operation.Type, + ) + } + return converted, nil +} + func openAIResponsesShellCallOutput( output conversation.ShellCallOutputBlock, ) (responses.ResponseInputItemUnionParam, error) { @@ -438,6 +681,28 @@ func openAIResponsesResponse(result *responses.Response) (*modelResponse, error) TimeoutMs: item.Action.TimeoutMs, MaxOutputLength: item.Action.MaxOutputLength, }) + case responses.ResponseApplyPatchToolCall: + hasToolUse = true + operation, err := openAIApplyPatchOperation(item.Operation) + if err != nil { + return nil, fmt.Errorf("llm: OpenAI Responses returned invalid apply patch operation: %w", err) + } + content = append(content, conversation.ApplyPatchCallBlock{ + ID: item.ID, CallID: item.CallID, Source: conversation.ApplyPatchSourceNative, + Operation: &operation, + }) + case responses.ResponseCustomToolCall: + if item.Name != "apply_patch" { + continue + } + hasToolUse = true + if item.Input == "" { + return nil, fmt.Errorf("llm: OpenAI Responses returned an empty custom apply patch input") + } + content = append(content, conversation.ApplyPatchCallBlock{ + ID: item.ID, CallID: item.CallID, Source: conversation.ApplyPatchSourceCustom, + Patch: item.Input, + }) case responses.ResponseReasoningItem: parts := make([]string, 0, len(item.Content)+len(item.Summary)) for _, part := range item.Content { @@ -483,6 +748,12 @@ type openAIResponsesStreamEvent struct { CallID string `json:"call_id"` Name string `json:"name"` Arguments string `json:"arguments"` + Input string `json:"input"` + Operation struct { + Type string `json:"type"` + Path string `json:"path"` + Diff string `json:"diff"` + } `json:"operation"` } `json:"item"` } @@ -493,10 +764,16 @@ func emitOpenAIResponsesEvent(handler modelStreamHandler, event openAIResponsesS case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": return emit(handler, modelStreamEvent{Type: modelStreamEventReasoningDelta, Index: event.OutputIndex, Delta: event.Delta}) case "response.output_item.added": - if event.Item.Type == "function_call" { + if event.Item.Type == "function_call" || + event.Item.Type == "apply_patch_call" || + (event.Item.Type == "custom_tool_call" && event.Item.Name == "apply_patch") { + name := event.Item.Name + if event.Item.Type == "apply_patch_call" { + name = "apply_patch" + } return emit(handler, modelStreamEvent{ Type: modelStreamEventToolUseStart, Index: event.OutputIndex, - ToolUseID: event.Item.CallID, ToolName: event.Item.Name, + ToolUseID: event.Item.CallID, ToolName: name, }) } case "response.function_call_arguments.delta": @@ -509,6 +786,31 @@ func emitOpenAIResponsesEvent(handler modelStreamHandler, event openAIResponsesS ToolInput: shared.RawJSON(event.Item.Arguments), }) } + if event.Item.Type == "apply_patch_call" { + operation := conversation.ApplyPatchOperation{ + Type: conversation.ApplyPatchOperationType(event.Item.Operation.Type), + Path: event.Item.Operation.Path, + Diff: event.Item.Operation.Diff, + } + input := conversation.ApplyPatchCallBlock{ + CallID: event.Item.CallID, Source: conversation.ApplyPatchSourceNative, + Operation: &operation, + }.ToolUseBlock().Input + return emit(handler, modelStreamEvent{ + Type: modelStreamEventToolUseDone, Index: event.OutputIndex, + ToolUseID: event.Item.CallID, ToolName: "apply_patch", ToolInput: input, + }) + } + if event.Item.Type == "custom_tool_call" && event.Item.Name == "apply_patch" { + input := conversation.ApplyPatchCallBlock{ + CallID: event.Item.CallID, Source: conversation.ApplyPatchSourceCustom, + Patch: event.Item.Input, + }.ToolUseBlock().Input + return emit(handler, modelStreamEvent{ + Type: modelStreamEventToolUseDone, Index: event.OutputIndex, + ToolUseID: event.Item.CallID, ToolName: "apply_patch", ToolInput: input, + }) + } } return nil diff --git a/packages/agenty-core/pkg/infra/rpc/adapter/adapter_test.go b/packages/agenty-core/pkg/infra/rpc/adapter/adapter_test.go index 0a78d2f..03728b2 100644 --- a/packages/agenty-core/pkg/infra/rpc/adapter/adapter_test.go +++ b/packages/agenty-core/pkg/infra/rpc/adapter/adapter_test.go @@ -164,17 +164,17 @@ func TestAdapterAgentCreateAndGet(t *testing.T) { d := newDispatcher(t) create := call(t, d, request(1, "agent.create", map[string]any{ - "slug": "coder", "name": "Code Assistant", "soul": "You code.", + "code": "coder", "name": "Code Assistant", "soul": "You code.", })) if errCode(create) != 0 { t.Fatalf("create error: %+v", create["error"]) } result := create["result"].(map[string]any) - if result["slug"] != "coder" { - t.Errorf("slug = %v, want coder", result["slug"]) + if result["code"] != "coder" { + t.Errorf("code = %v, want coder", result["code"]) } - got := call(t, d, request(2, "agent.get", map[string]any{"slug": "coder"})) + got := call(t, d, request(2, "agent.get", map[string]any{"code": "coder"})) if errCode(got) != 0 { t.Fatalf("get error: %+v", got["error"]) } @@ -210,15 +210,15 @@ func TestAdapterEmptyCollectionsUseArrays(t *testing.T) { func TestAdapterAgentNotFound(t *testing.T) { d := newDispatcher(t) - resp := call(t, d, request(1, "agent.get", map[string]any{"slug": "missing"})) + resp := call(t, d, request(1, "agent.get", map[string]any{"code": "missing"})) if code := errCode(resp); code != rpc.ErrCodeNotFound { t.Errorf("code = %d, want %d (not found)", code, rpc.ErrCodeNotFound) } } -func TestAdapterAgentInvalidSlug(t *testing.T) { +func TestAdapterAgentInvalidCode(t *testing.T) { d := newDispatcher(t) - resp := call(t, d, request(1, "agent.create", map[string]any{"slug": "Bad Slug", "name": "x"})) + resp := call(t, d, request(1, "agent.create", map[string]any{"code": "Bad Code", "name": "x"})) if code := errCode(resp); code != rpc.ErrCodeInvalidParams { t.Errorf("code = %d, want %d (invalid params)", code, rpc.ErrCodeInvalidParams) } @@ -226,8 +226,8 @@ func TestAdapterAgentInvalidSlug(t *testing.T) { func TestAdapterAgentDuplicate(t *testing.T) { d := newDispatcher(t) - call(t, d, request(1, "agent.create", map[string]any{"slug": "coder", "name": "A"})) - resp := call(t, d, request(2, "agent.create", map[string]any{"slug": "coder", "name": "B"})) + call(t, d, request(1, "agent.create", map[string]any{"code": "coder", "name": "A"})) + resp := call(t, d, request(2, "agent.create", map[string]any{"code": "coder", "name": "B"})) if code := errCode(resp); code != rpc.ErrCodeAlreadyExists { t.Errorf("code = %d, want %d (already exists)", code, rpc.ErrCodeAlreadyExists) } @@ -237,11 +237,11 @@ func TestAdapterProviderAddModel(t *testing.T) { d := newDispatcher(t) call(t, d, request(1, "provider.create", map[string]any{ - "slug": "anthropic", "name": "Anthropic", "type": "anthropic", + "code": "anthropic", "name": "Anthropic", "type": "anthropic", })) resp := call(t, d, request(2, "provider.addModel", map[string]any{ - "providerSlug": "anthropic", - "modelSlug": "claude-opus-4-8", + "providerCode": "anthropic", + "modelCode": "claude-opus-4-8", "name": "Claude Opus 4.8", "contextWindow": 200000, "maxOutputTokens": 32000, @@ -276,19 +276,19 @@ func TestAdapterInitializeJourney(t *testing.T) { params map[string]any }{ {method: "provider.create", params: map[string]any{ - "slug": "openai", "name": "OpenAI", "type": "openai", "apiKey": "test", + "code": "openai", "name": "OpenAI", "type": "openai", "apiKey": "test", }}, {method: "provider.addModel", params: map[string]any{ - "providerSlug": "openai", "modelSlug": "gpt-test", "name": "GPT Test", + "providerCode": "openai", "modelCode": "gpt-test", "name": "GPT Test", "contextWindow": 128000, "maxOutputTokens": 16384, "isDefault": true, }}, {method: "agent.create", params: map[string]any{ - "slug": "default", "name": "Default", "soul": "Be helpful.", "isDefault": true, + "code": "default", "name": "Default", "soul": "Be helpful.", "isDefault": true, "defaultContextWindow": 128000, - "defaultModel": map[string]any{"providerSlug": "openai", "modelSlug": "gpt-test"}, + "defaultModel": map[string]any{"providerCode": "openai", "modelCode": "gpt-test"}, }}, {method: "initialize.complete", params: map[string]any{ - "agentSlug": "default", "providerSlug": "openai", "modelSlug": "gpt-test", + "agentCode": "default", "providerCode": "openai", "modelCode": "gpt-test", }}, } { resp := call(t, d, request(id+2, step.method, step.params)) @@ -307,9 +307,9 @@ func TestAdapterSessionCreateAndGet(t *testing.T) { d := newDispatcher(t) create := call(t, d, request(1, "session.create", map[string]any{ - "agentSlug": "coder", - "providerSlug": "anthropic", - "modelSlug": "claude-opus-4-8", + "agentCode": "coder", + "providerCode": "anthropic", + "modelCode": "claude-opus-4-8", "contextWindow": 200000, })) if errCode(create) != 0 { @@ -338,9 +338,9 @@ func TestAdapterSessionList(t *testing.T) { d := newDispatcher(t) for range 3 { call(t, d, request(1, "session.create", map[string]any{ - "agentSlug": "coder", - "providerSlug": "anthropic", - "modelSlug": "claude-opus-4-8", + "agentCode": "coder", + "providerCode": "anthropic", + "modelCode": "claude-opus-4-8", })) } resp := call(t, d, request(2, "session.list", map[string]any{})) @@ -435,7 +435,7 @@ func createExecutableSession(t *testing.T, d *rpc.Dispatcher) string { t.Helper() createAgent := call(t, d, request(1, "agent.create", map[string]any{ - "slug": "coder", + "code": "coder", "name": "Coder", "soul": "Complete the task.", })) @@ -443,7 +443,7 @@ func createExecutableSession(t *testing.T, d *rpc.Dispatcher) string { t.Fatalf("create agent error: %+v", createAgent["error"]) } createProvider := call(t, d, request(2, "provider.create", map[string]any{ - "slug": "openai", + "code": "openai", "name": "OpenAI", "type": "openai_completions", "apiKey": "test-key", @@ -452,8 +452,8 @@ func createExecutableSession(t *testing.T, d *rpc.Dispatcher) string { t.Fatalf("create provider error: %+v", createProvider["error"]) } addModel := call(t, d, request(3, "provider.addModel", map[string]any{ - "providerSlug": "openai", - "modelSlug": "gpt-test", + "providerCode": "openai", + "modelCode": "gpt-test", "name": "GPT Test", "contextWindow": 128000, "maxOutputTokens": 100000, @@ -462,9 +462,9 @@ func createExecutableSession(t *testing.T, d *rpc.Dispatcher) string { t.Fatalf("add model error: %+v", addModel["error"]) } created := call(t, d, request(4, "session.create", map[string]any{ - "agentSlug": "coder", - "providerSlug": "openai", - "modelSlug": "gpt-test", + "agentCode": "coder", + "providerCode": "openai", + "modelCode": "gpt-test", "contextWindow": 128000, })) if errCode(created) != 0 { @@ -559,19 +559,19 @@ func TestAdapterChunkedAgentCreate(t *testing.T) { rpc.RegisterChunkHandlers(d, asm) resp := callChunked(t, d, 1, "agent.create", map[string]any{ - "slug": "chunked", "name": "Chunked Agent", "soul": "You shard.", + "code": "chunked", "name": "Chunked Agent", "soul": "You shard.", }) if errCode(resp) != 0 { t.Fatalf("chunked create error: %+v", resp["error"]) } result := resp["result"].(map[string]any) - if result["slug"] != "chunked" { - t.Errorf("slug = %v, want chunked", result["slug"]) + if result["code"] != "chunked" { + t.Errorf("code = %v, want chunked", result["code"]) } // The committed upload must be indistinguishable from a direct call: a // subsequent agent.get reads back the same record. - got := call(t, d, request(2, "agent.get", map[string]any{"slug": "chunked"})) + got := call(t, d, request(2, "agent.get", map[string]any{"code": "chunked"})) if errCode(got) != 0 { t.Fatalf("get error: %+v", got["error"]) } diff --git a/packages/agenty-core/pkg/infra/rpc/adapter/agent.go b/packages/agenty-core/pkg/infra/rpc/adapter/agent.go index eeb43e6..ed9fc38 100644 --- a/packages/agenty-core/pkg/infra/rpc/adapter/agent.go +++ b/packages/agenty-core/pkg/infra/rpc/adapter/agent.go @@ -8,9 +8,9 @@ import ( "github.com/masteryyh/agenty-core/pkg/infra/rpc" ) -// slugParams identifies a resource by its slug. -type slugParams struct { - Slug string `json:"slug"` +// codeParams identifies a resource by its code. +type codeParams struct { + Code string `json:"code"` } // RegisterAgentHandlers registers agent.* methods on d. @@ -23,7 +23,7 @@ func RegisterAgentHandlers(d *rpc.Dispatcher, svc *application.AgentService) { } type agentCreateParams struct { - Slug string `json:"slug"` + Code string `json:"code"` application.AgentInput } @@ -33,17 +33,17 @@ func agentCreate(svc *application.AgentService) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.Create(ctx, p.Slug, p.AgentInput)) + return wrap(svc.Create(ctx, p.Code, p.AgentInput)) } } func agentGet(svc *application.AgentService) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (any, error) { - var p slugParams + var p codeParams if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.Get(ctx, p.Slug)) + return wrap(svc.Get(ctx, p.Code)) } } @@ -58,7 +58,7 @@ func agentList(svc *application.AgentService) rpc.Handler { } type agentUpdateParams struct { - Slug string `json:"slug"` + Code string `json:"code"` application.AgentUpdate } @@ -68,19 +68,19 @@ func agentUpdate(svc *application.AgentService) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.Update(ctx, p.Slug, p.AgentUpdate)) + return wrap(svc.Update(ctx, p.Code, p.AgentUpdate)) } } func agentDelete(svc *application.AgentService) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (any, error) { - var p slugParams + var p codeParams if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - if err := svc.Delete(ctx, p.Slug); err != nil { + if err := svc.Delete(ctx, p.Code); err != nil { return nil, toRPCError(err) } - return map[string]any{"slug": p.Slug, "deleted": true}, nil + return map[string]any{"code": p.Code, "deleted": true}, nil } } diff --git a/packages/agenty-core/pkg/infra/rpc/adapter/provider.go b/packages/agenty-core/pkg/infra/rpc/adapter/provider.go index ebb5b47..70cde6c 100644 --- a/packages/agenty-core/pkg/infra/rpc/adapter/provider.go +++ b/packages/agenty-core/pkg/infra/rpc/adapter/provider.go @@ -20,7 +20,7 @@ func RegisterProviderHandlers(d *rpc.Dispatcher, svc *application.ProviderServic } type providerCreateParams struct { - Slug string `json:"slug"` + Code string `json:"code"` application.ProviderInput } @@ -30,17 +30,17 @@ func providerCreate(svc *application.ProviderService) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.Create(ctx, p.Slug, p.ProviderInput)) + return wrap(svc.Create(ctx, p.Code, p.ProviderInput)) } } func providerGet(svc *application.ProviderService) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (any, error) { - var p slugParams + var p codeParams if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.Get(ctx, p.Slug)) + return wrap(svc.Get(ctx, p.Code)) } } @@ -55,7 +55,7 @@ func providerList(svc *application.ProviderService) rpc.Handler { } type providerUpdateParams struct { - Slug string `json:"slug"` + Code string `json:"code"` application.ProviderUpdate } @@ -65,32 +65,32 @@ func providerUpdate(svc *application.ProviderService) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.Update(ctx, p.Slug, p.ProviderUpdate)) + return wrap(svc.Update(ctx, p.Code, p.ProviderUpdate)) } } func providerDelete(svc *application.ProviderService) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (any, error) { - var p slugParams + var p codeParams if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - if err := svc.Delete(ctx, p.Slug); err != nil { + if err := svc.Delete(ctx, p.Code); err != nil { return nil, toRPCError(err) } - return map[string]any{"slug": p.Slug, "deleted": true}, nil + return map[string]any{"code": p.Code, "deleted": true}, nil } } // modelTargetParams identifies a model within a provider. type modelTargetParams struct { - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` } type providerAddModelParams struct { - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` application.ModelInput } @@ -100,7 +100,7 @@ func providerAddModel(svc *application.ProviderService) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.AddModel(ctx, p.ProviderSlug, p.ModelSlug, p.ModelInput)) + return wrap(svc.AddModel(ctx, p.ProviderCode, p.ModelCode, p.ModelInput)) } } @@ -110,6 +110,6 @@ func providerRemoveModel(svc *application.ProviderService) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(svc.RemoveModel(ctx, p.ProviderSlug, p.ModelSlug)) + return wrap(svc.RemoveModel(ctx, p.ProviderCode, p.ModelCode)) } } diff --git a/packages/agenty-core/pkg/infra/rpc/adapter/session.go b/packages/agenty-core/pkg/infra/rpc/adapter/session.go index 1ff4cde..2675205 100644 --- a/packages/agenty-core/pkg/infra/rpc/adapter/session.go +++ b/packages/agenty-core/pkg/infra/rpc/adapter/session.go @@ -54,7 +54,7 @@ func sessionGet(svc *application.SessionService) rpc.Handler { } type sessionListParams struct { - AgentSlug string `json:"agentSlug,omitempty"` + AgentCode string `json:"agentCode,omitempty"` Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` } @@ -66,7 +66,7 @@ func sessionList(svc *application.SessionService) rpc.Handler { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } return wrap(svc.List(ctx, application.SessionListQuery{ - AgentSlug: p.AgentSlug, + AgentCode: p.AgentCode, Limit: p.Limit, Offset: p.Offset, })) @@ -103,8 +103,8 @@ func sessionSetTitle(svc *application.SessionService) rpc.Handler { type sessionSetModelParams struct { ID string `json:"id"` - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` } func sessionSetModel(execution *agentloop.Engine) rpc.Handler { @@ -113,7 +113,7 @@ func sessionSetModel(execution *agentloop.Engine) rpc.Handler { if err := decodeParams(params, &p); err != nil { return nil, rpc.InvalidParams("invalid params: " + err.Error()) } - return wrap(execution.SetModel(ctx, p.ID, p.ProviderSlug, p.ModelSlug)) + return wrap(execution.SetModel(ctx, p.ID, p.ProviderCode, p.ModelCode)) } } diff --git a/packages/agenty-core/pkg/infra/storage/agent.go b/packages/agenty-core/pkg/infra/storage/agent.go index 5181278..0a31679 100644 --- a/packages/agenty-core/pkg/infra/storage/agent.go +++ b/packages/agenty-core/pkg/infra/storage/agent.go @@ -22,8 +22,8 @@ func NewAgentRepository(agentsDir string) *AgentRepository { return &AgentRepository{agentsDir: agentsDir} } -func (r *AgentRepository) Get(ctx context.Context, slug shared.Slug) (*agent.Agent, error) { - path := filepath.Join(r.agentsDir, slug.String()+".json") +func (r *AgentRepository) Get(ctx context.Context, code shared.Code) (*agent.Agent, error) { + path := filepath.Join(r.agentsDir, code.String()+".json") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -54,13 +54,13 @@ func (r *AgentRepository) List(ctx context.Context) ([]*agent.Agent, error) { continue } - slugStr := entry.Name()[:len(entry.Name())-5] - slug, err := shared.NewSlug(slugStr) + codeStr := entry.Name()[:len(entry.Name())-5] + code, err := shared.NewCode(codeStr) if err != nil { continue } - a, err := r.Get(ctx, slug) + a, err := r.Get(ctx, code) if errors.Is(err, ErrAgentNotFound) { continue } @@ -82,12 +82,12 @@ func (r *AgentRepository) Save(ctx context.Context, a *agent.Agent) error { return err } - path := filepath.Join(r.agentsDir, a.Slug.String()+".json") + path := filepath.Join(r.agentsDir, a.Code.String()+".json") return os.WriteFile(path, data, 0600) } -func (r *AgentRepository) Delete(ctx context.Context, slug shared.Slug) error { - path := filepath.Join(r.agentsDir, slug.String()+".json") +func (r *AgentRepository) Delete(ctx context.Context, code shared.Code) error { + path := filepath.Join(r.agentsDir, code.String()+".json") err := os.Remove(path) if os.IsNotExist(err) { return ErrAgentNotFound diff --git a/packages/agenty-core/pkg/infra/storage/agent_test.go b/packages/agenty-core/pkg/infra/storage/agent_test.go index b1f74ed..b479fcb 100644 --- a/packages/agenty-core/pkg/infra/storage/agent_test.go +++ b/packages/agenty-core/pkg/infra/storage/agent_test.go @@ -23,11 +23,11 @@ func TestAgentSaveAndGet(t *testing.T) { t.Fatal(err) } a.Soul = "You are a helpful coding assistant." - modelID, err := shared.NewModelID("claude-opus") + modelCode, err := shared.NewModelCode("claude-opus") if err != nil { t.Fatal(err) } - model := shared.NewModelRef(mustSlug("anthropic"), modelID) + model := shared.NewModelRef(mustCode("anthropic"), modelCode) a.DefaultModel = &model a.DefaultContextWindow = 200_000 a.DefaultReasoningEffort = shared.ReasoningHigh @@ -36,13 +36,13 @@ func TestAgentSaveAndGet(t *testing.T) { t.Fatalf("Save: %v", err) } - loaded, err := repo.Get(ctx, a.Slug) + loaded, err := repo.Get(ctx, a.Code) if err != nil { t.Fatalf("Get: %v", err) } - if loaded.Slug != a.Slug { - t.Errorf("slug = %s, want %s", loaded.Slug, a.Slug) + if loaded.Code != a.Code { + t.Errorf("code = %s, want %s", loaded.Code, a.Code) } if loaded.Name != a.Name { t.Errorf("name = %s, want %s", loaded.Name, a.Name) @@ -108,11 +108,11 @@ func TestAgentDelete(t *testing.T) { t.Fatal(err) } - if err := repo.Delete(ctx, a.Slug); err != nil { + if err := repo.Delete(ctx, a.Code); err != nil { t.Fatalf("Delete: %v", err) } - _, err := repo.Get(ctx, a.Slug) + _, err := repo.Get(ctx, a.Code) if err != ErrAgentNotFound { t.Errorf("Get after Delete = %v, want ErrAgentNotFound", err) } @@ -139,8 +139,8 @@ func TestAgentDefault(t *testing.T) { if err != nil { t.Fatalf("Default: %v", err) } - if def.Slug != a2.Slug { - t.Errorf("Default returned %s, want %s", def.Slug, a2.Slug) + if def.Code != a2.Code { + t.Errorf("Default returned %s, want %s", def.Code, a2.Code) } } @@ -162,7 +162,7 @@ func TestAgentDefaultReturnsNotFoundWhenNone(t *testing.T) { func TestAgentGetReturnsNotFoundWhenMissing(t *testing.T) { repo := newAgentRepo(t) - _, err := repo.Get(context.Background(), mustSlug("unknown")) + _, err := repo.Get(context.Background(), mustCode("unknown")) if err != ErrAgentNotFound { t.Errorf("Get() = %v, want ErrAgentNotFound", err) } diff --git a/packages/agenty-core/pkg/infra/storage/catalog.go b/packages/agenty-core/pkg/infra/storage/catalog.go index 4dc07fb..6d3843a 100644 --- a/packages/agenty-core/pkg/infra/storage/catalog.go +++ b/packages/agenty-core/pkg/infra/storage/catalog.go @@ -2,11 +2,10 @@ package storage import ( "context" - "errors" - "net/url" + "fmt" "os" "path/filepath" - "time" + "strings" json "github.com/bytedance/sonic" @@ -24,8 +23,8 @@ func NewCatalogRepository(providersDir string) *CatalogRepository { return &CatalogRepository{providersDir: providersDir} } -func (r *CatalogRepository) Get(ctx context.Context, slug shared.Slug) (*catalog.Provider, error) { - providerPath := filepath.Join(r.providersDir, slug.String(), "provider.json") +func (r *CatalogRepository) Get(_ context.Context, code shared.Code) (*catalog.Provider, error) { + providerPath := filepath.Join(r.providersDir, code.String()+".json") data, err := os.ReadFile(providerPath) if err != nil { if os.IsNotExist(err) { @@ -34,43 +33,13 @@ func (r *CatalogRepository) Get(ctx context.Context, slug shared.Slug) (*catalog return nil, err } - var p catalog.Provider - if err := json.Unmarshal(data, &p); err != nil { + var provider catalog.Provider + if err := json.Unmarshal(data, &provider); err != nil { return nil, err } + normalizeModels(&provider) - modelsDir := filepath.Join(r.providersDir, slug.String(), "models") - entries, err := os.ReadDir(modelsDir) - if err != nil && !os.IsNotExist(err) { - return nil, err - } - if p.Models == nil { - p.Models = make([]catalog.Model, 0, len(entries)) - } else { - for index := range p.Models { - p.Models[index].MaxOutputTokens = catalog.DefaultMaxOutputTokens - } - } - - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { - continue - } - - modelData, err := os.ReadFile(filepath.Join(modelsDir, entry.Name())) - if err != nil { - return nil, err - } - - var m catalog.Model - if err := json.Unmarshal(modelData, &m); err != nil { - return nil, err - } - m.MaxOutputTokens = catalog.DefaultMaxOutputTokens - p.Models = append(p.Models, m) - } - - return &p, nil + return &provider, nil } func (r *CatalogRepository) List(ctx context.Context) ([]*catalog.Provider, error) { @@ -84,113 +53,62 @@ func (r *CatalogRepository) List(ctx context.Context) ([]*catalog.Provider, erro } for _, entry := range entries { - if !entry.IsDir() { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } - slug, err := shared.NewSlug(entry.Name()) + code, err := shared.NewCode(strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))) if err != nil { continue } - p, err := r.Get(ctx, slug) + provider, err := r.Get(ctx, code) if err != nil { - if errors.Is(err, ErrProviderNotFound) { + if err == ErrProviderNotFound { continue } return nil, err } - providers = append(providers, p) + providers = append(providers, provider) } + return providers, nil } -func (r *CatalogRepository) Save(ctx context.Context, provider *catalog.Provider) error { - providerDir := filepath.Join(r.providersDir, provider.Slug.String()) - if err := os.MkdirAll(providerDir, 0700); err != nil { - return err - } - - type providerFile struct { - Slug shared.Slug `json:"slug"` - Name string `json:"name"` - Type catalog.APIType `json:"type"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - Metadata shared.Metadata `json:"metadata,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - } - pf := providerFile{ - Slug: provider.Slug, - Name: provider.Name, - Type: provider.Type, - BaseURL: provider.BaseURL, - APIKey: provider.APIKey, - Metadata: provider.Metadata, - CreatedAt: provider.CreatedAt, - UpdatedAt: provider.UpdatedAt, +func (r *CatalogRepository) Save(_ context.Context, provider *catalog.Provider) error { + if provider == nil || !provider.Code.Valid() { + return fmt.Errorf("storage: invalid provider code") } - providerData, err := json.MarshalIndent(pf, "", " ") - if err != nil { + if err := os.MkdirAll(r.providersDir, 0700); err != nil { return err } - providerPath := filepath.Join(providerDir, "provider.json") - if err := os.WriteFile(providerPath, providerData, 0600); err != nil { - return err - } - - modelsDir := filepath.Join(providerDir, "models") - if err := os.MkdirAll(modelsDir, 0700); err != nil { + normalizeModels(provider) + providerData, err := json.MarshalIndent(provider, "", " ") + if err != nil { return err } - for _, model := range provider.Models { - model.MaxOutputTokens = catalog.DefaultMaxOutputTokens - modelData, err := json.MarshalIndent(model, "", " ") - if err != nil { - return err - } - - modelPath := filepath.Join(modelsDir, modelFileName(model.Slug)) - if err := os.WriteFile(modelPath, modelData, 0600); err != nil { - return err - } - } - - return nil + providerPath := filepath.Join(r.providersDir, provider.Code.String()+".json") + return os.WriteFile(providerPath, providerData, 0600) } -func (r *CatalogRepository) Delete(ctx context.Context, slug shared.Slug) error { - providerDir := filepath.Join(r.providersDir, slug.String()) - if _, err := os.Stat(providerDir); err != nil { +func (r *CatalogRepository) Delete(_ context.Context, code shared.Code) error { + providerPath := filepath.Join(r.providersDir, code.String()+".json") + if err := os.Remove(providerPath); err != nil { if os.IsNotExist(err) { return ErrProviderNotFound } return err } - if err := os.RemoveAll(providerDir); err != nil { - return err - } return nil } -func (r *CatalogRepository) DeleteModel(ctx context.Context, providerSlug shared.Slug, modelSlug shared.ModelID) error { - modelPath := filepath.Join(r.providersDir, providerSlug.String(), "models", modelFileName(modelSlug)) - if err := os.Remove(modelPath); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err +func normalizeModels(provider *catalog.Provider) { + if provider.Models == nil { + provider.Models = make([]catalog.Model, 0) } - - providerDir := filepath.Join(r.providersDir, providerSlug.String()) - if _, derr := os.Stat(providerDir); os.IsNotExist(derr) { - return ErrProviderNotFound + for index := range provider.Models { + provider.Models[index].MaxOutputTokens = catalog.DefaultMaxOutputTokens } - return catalog.ErrModelNotFound -} - -func modelFileName(modelID shared.ModelID) string { - return url.PathEscape(modelID.String()) + ".json" } diff --git a/packages/agenty-core/pkg/infra/storage/catalog_test.go b/packages/agenty-core/pkg/infra/storage/catalog_test.go index 19b51f6..48964b2 100644 --- a/packages/agenty-core/pkg/infra/storage/catalog_test.go +++ b/packages/agenty-core/pkg/infra/storage/catalog_test.go @@ -18,12 +18,12 @@ func newCatalogRepo(t *testing.T) *CatalogRepository { return NewCatalogRepository(filepath.Join(t.TempDir(), "providers")) } -func mustCatalogModelID(value string) shared.ModelID { - modelID, err := shared.NewModelID(value) +func mustCatalogModelCode(value string) shared.ModelCode { + modelCode, err := shared.NewModelCode(value) if err != nil { panic(err) } - return modelID + return modelCode } func TestCatalogSaveAndGet(t *testing.T) { @@ -38,7 +38,7 @@ func TestCatalogSaveAndGet(t *testing.T) { provider.APIKey = "sk-ant-test" model1 := catalog.Model{ - Slug: mustCatalogModelID("org/claude-opus[fast]"), + Code: mustCatalogModelCode(`org/claude\\claude-opus[fast]`), Name: "Claude Opus 4.8", ContextWindow: 200000, MaxOutputTokens: 32000, @@ -51,7 +51,7 @@ func TestCatalogSaveAndGet(t *testing.T) { UpdatedAt: time.Now().UTC(), } model2 := catalog.Model{ - Slug: mustCatalogModelID("claude-haiku-4-5"), + Code: mustCatalogModelCode("claude-haiku-4-5"), Name: "Claude Haiku 4.5", ContextWindow: 200000, MaxOutputTokens: 8000, @@ -64,28 +64,28 @@ func TestCatalogSaveAndGet(t *testing.T) { if err := repo.Save(ctx, provider); err != nil { t.Fatalf("Save: %v", err) } - modelData, err := os.ReadFile(filepath.Join(repo.providersDir, provider.Slug.String(), "models", modelFileName(model1.Slug))) + providerData, err := os.ReadFile(filepath.Join(repo.providersDir, provider.Code.String()+".json")) if err != nil { - t.Fatalf("read model file: %v", err) + t.Fatalf("read provider file: %v", err) } - var persistedModel map[string]shared.RawJSON - if err := json.Unmarshal(modelData, &persistedModel); err != nil { - t.Fatalf("decode model file: %v", err) + var persistedProvider catalog.Provider + if err := json.Unmarshal(providerData, &persistedProvider); err != nil { + t.Fatalf("decode provider file: %v", err) } - if _, ok := persistedModel["reasoningEffortMapping"]; !ok { - t.Errorf("persisted model keys = %v, want reasoningEffortMapping", persistedModel) + if len(persistedProvider.Models) != 2 { + t.Fatalf("persisted %d models, want 2", len(persistedProvider.Models)) } - if _, ok := persistedModel["maxOutputTokens"]; !ok { - t.Errorf("persisted model keys = %v, want maxOutputTokens", persistedModel) + if persistedProvider.Models[0].Code != model1.Code && persistedProvider.Models[1].Code != model1.Code { + t.Errorf("persisted models = %+v, want model %s", persistedProvider.Models, model1.Code) } - loaded, err := repo.Get(ctx, provider.Slug) + loaded, err := repo.Get(ctx, provider.Code) if err != nil { t.Fatalf("Get: %v", err) } - if loaded.Slug != provider.Slug { - t.Errorf("slug = %s, want %s", loaded.Slug, provider.Slug) + if loaded.Code != provider.Code { + t.Errorf("code = %s, want %s", loaded.Code, provider.Code) } if loaded.Name != provider.Name { t.Errorf("name = %s, want %s", loaded.Name, provider.Name) @@ -94,13 +94,13 @@ func TestCatalogSaveAndGet(t *testing.T) { t.Fatalf("loaded %d models, want 2", len(loaded.Models)) } - // Models may load in any order; find by slug. + // Models may load in any order; find by code. var gotOpus, gotHaiku *catalog.Model for i := range loaded.Models { - if loaded.Models[i].Slug == model1.Slug { + if loaded.Models[i].Code == model1.Code { gotOpus = &loaded.Models[i] } - if loaded.Models[i].Slug == model2.Slug { + if loaded.Models[i].Code == model2.Code { gotHaiku = &loaded.Models[i] } } @@ -176,63 +176,59 @@ func TestCatalogDelete(t *testing.T) { t.Fatal(err) } - if err := repo.Delete(ctx, provider.Slug); err != nil { + if err := repo.Delete(ctx, provider.Code); err != nil { t.Fatalf("Delete: %v", err) } - _, err := repo.Get(ctx, provider.Slug) + _, err := repo.Get(ctx, provider.Code) if err != ErrProviderNotFound { t.Errorf("Get after Delete = %v, want ErrProviderNotFound", err) } - if err := repo.Delete(ctx, provider.Slug); err != ErrProviderNotFound { + if err := repo.Delete(ctx, provider.Code); err != ErrProviderNotFound { t.Errorf("Delete missing provider = %v, want ErrProviderNotFound", err) } } func TestCatalogGetReturnsNotFoundWhenMissing(t *testing.T) { repo := newCatalogRepo(t) - _, err := repo.Get(context.Background(), mustSlug("unknown")) + _, err := repo.Get(context.Background(), mustCode("unknown")) if err != ErrProviderNotFound { t.Errorf("Get() = %v, want ErrProviderNotFound", err) } } -func TestCatalogDeleteModel(t *testing.T) { +func TestCatalogSaveAfterRemovingModel(t *testing.T) { repo := newCatalogRepo(t) ctx := context.Background() provider, _ := catalog.NewProvider("anthropic", "Anthropic", catalog.APIAnthropic) now := time.Now().UTC() provider.Models = []catalog.Model{ - {Slug: mustCatalogModelID("claude-opus-4-8"), Name: "Opus", CreatedAt: now, UpdatedAt: now}, - {Slug: mustCatalogModelID("claude-haiku-4-5"), Name: "Haiku", CreatedAt: now, UpdatedAt: now}, + {Code: mustCatalogModelCode("claude-opus-4-8"), Name: "Opus", CreatedAt: now, UpdatedAt: now}, + {Code: mustCatalogModelCode("claude-haiku-4-5"), Name: "Haiku", CreatedAt: now, UpdatedAt: now}, } if err := repo.Save(ctx, provider); err != nil { t.Fatal(err) } - if err := repo.DeleteModel(ctx, provider.Slug, mustCatalogModelID("claude-haiku-4-5")); err != nil { - t.Fatalf("DeleteModel: %v", err) + provider.RemoveModel(mustCatalogModelCode("claude-haiku-4-5")) + if err := repo.Save(ctx, provider); err != nil { + t.Fatalf("Save after remove: %v", err) } - loaded, err := repo.Get(ctx, provider.Slug) + loaded, err := repo.Get(ctx, provider.Code) if err != nil { t.Fatal(err) } if len(loaded.Models) != 1 { t.Fatalf("loaded %d models, want 1 after delete", len(loaded.Models)) } - if loaded.Models[0].Slug != mustCatalogModelID("claude-opus-4-8") { - t.Errorf("remaining model = %s, want claude-opus-4-8", loaded.Models[0].Slug) - } - - // Deleting the same model again surfaces model-not-found, not a silent no-op. - if err := repo.DeleteModel(ctx, provider.Slug, mustCatalogModelID("claude-haiku-4-5")); err != catalog.ErrModelNotFound { - t.Errorf("DeleteModel missing model = %v, want catalog.ErrModelNotFound", err) + if loaded.Models[0].Code != mustCatalogModelCode("claude-opus-4-8") { + t.Errorf("remaining model = %s, want claude-opus-4-8", loaded.Models[0].Code) } - // Deleting from a missing provider surfaces provider-not-found. - if err := repo.DeleteModel(ctx, mustSlug("nope"), mustCatalogModelID("x")); err != ErrProviderNotFound { - t.Errorf("DeleteModel missing provider = %v, want ErrProviderNotFound", err) + providerPath := filepath.Join(repo.providersDir, provider.Code.String()+".json") + if _, err := os.Stat(providerPath); err != nil { + t.Fatalf("provider file after remove: %v", err) } } diff --git a/packages/agenty-core/pkg/infra/storage/conversation.go b/packages/agenty-core/pkg/infra/storage/conversation.go index 0ea6bed..acdab6c 100644 --- a/packages/agenty-core/pkg/infra/storage/conversation.go +++ b/packages/agenty-core/pkg/infra/storage/conversation.go @@ -129,22 +129,22 @@ func (r *ConversationRepository) Delete(ctx context.Context, id uuid.UUID) error func (r *ConversationRepository) upsertSession(ctx context.Context, sum conversation.SessionSummary) error { _, err := r.db.ExecContext(ctx, ` - INSERT INTO sessions (id, title, agent_slug, last_provider_slug, last_model_slug, context_window, last_reasoning_effort, created_at, updated_at) + INSERT INTO sessions (id, title, agent_code, last_provider_code, last_model_code, context_window, last_reasoning_effort, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, - agent_slug = excluded.agent_slug, - last_provider_slug = excluded.last_provider_slug, - last_model_slug = excluded.last_model_slug, + agent_code = excluded.agent_code, + last_provider_code = excluded.last_provider_code, + last_model_code = excluded.last_model_code, context_window = excluded.context_window, last_reasoning_effort = excluded.last_reasoning_effort, updated_at = excluded.updated_at `, sum.ID.String(), sum.Title, - sum.AgentSlug.String(), - sum.LastProviderSlug.String(), - sum.LastModelSlug.String(), + sum.AgentCode.String(), + sum.LastProviderCode.String(), + sum.LastModelCode.String(), sum.ContextWindow, sum.LastReasoningEffort, sum.CreatedAt.Format(time.RFC3339), @@ -158,7 +158,7 @@ func (r *ConversationRepository) getSession(ctx context.Context, id uuid.UUID) ( var idStr, agentStr, providerStr, modelStr, effortStr, createdStr, updatedStr string err := r.db.QueryRowContext(ctx, ` - SELECT id, title, agent_slug, last_provider_slug, last_model_slug, context_window, last_reasoning_effort, created_at, updated_at + SELECT id, title, agent_code, last_provider_code, last_model_code, context_window, last_reasoning_effort, created_at, updated_at FROM sessions WHERE id = ? `, id.String()).Scan(&idStr, &sum.Title, &agentStr, &providerStr, &modelStr, &sum.ContextWindow, &effortStr, &createdStr, &updatedStr) @@ -169,16 +169,16 @@ func (r *ConversationRepository) getSession(ctx context.Context, id uuid.UUID) ( if sum.ID, err = uuid.Parse(idStr); err != nil { return conversation.SessionSummary{}, err } - if sum.AgentSlug, err = shared.NewSlug(agentStr); err != nil { + if sum.AgentCode, err = shared.NewCode(agentStr); err != nil { return conversation.SessionSummary{}, err } if providerStr != "" { - if sum.LastProviderSlug, err = shared.NewSlug(providerStr); err != nil { + if sum.LastProviderCode, err = shared.NewCode(providerStr); err != nil { return conversation.SessionSummary{}, err } } if modelStr != "" { - if sum.LastModelSlug, err = shared.NewModelID(modelStr); err != nil { + if sum.LastModelCode, err = shared.NewModelCode(modelStr); err != nil { return conversation.SessionSummary{}, err } } @@ -194,12 +194,12 @@ func (r *ConversationRepository) getSession(ctx context.Context, id uuid.UUID) ( } func (r *ConversationRepository) listSessions(ctx context.Context, query conversation.ListQuery) ([]conversation.SessionSummary, error) { - q := "SELECT id, title, agent_slug, last_provider_slug, last_model_slug, context_window, last_reasoning_effort, created_at, updated_at FROM sessions" + q := "SELECT id, title, agent_code, last_provider_code, last_model_code, context_window, last_reasoning_effort, created_at, updated_at FROM sessions" args := []any{} - if query.AgentSlug != nil { - q += " WHERE agent_slug = ?" - args = append(args, query.AgentSlug.String()) + if query.AgentCode != nil { + q += " WHERE agent_code = ?" + args = append(args, query.AgentCode.String()) } q += " ORDER BY updated_at DESC" @@ -231,16 +231,16 @@ func (r *ConversationRepository) listSessions(ctx context.Context, query convers if sum.ID, err = uuid.Parse(idStr); err != nil { return nil, err } - if sum.AgentSlug, err = shared.NewSlug(agentStr); err != nil { + if sum.AgentCode, err = shared.NewCode(agentStr); err != nil { return nil, err } if providerStr != "" { - if sum.LastProviderSlug, err = shared.NewSlug(providerStr); err != nil { + if sum.LastProviderCode, err = shared.NewCode(providerStr); err != nil { return nil, err } } if modelStr != "" { - if sum.LastModelSlug, err = shared.NewModelID(modelStr); err != nil { + if sum.LastModelCode, err = shared.NewModelCode(modelStr); err != nil { return nil, err } } diff --git a/packages/agenty-core/pkg/infra/storage/conversation_test.go b/packages/agenty-core/pkg/infra/storage/conversation_test.go index a4cd9b5..ab10ae4 100644 --- a/packages/agenty-core/pkg/infra/storage/conversation_test.go +++ b/packages/agenty-core/pkg/infra/storage/conversation_test.go @@ -16,16 +16,16 @@ import ( "github.com/masteryyh/agenty-core/pkg/domain/shared" ) -func mustSlug(s string) shared.Slug { - slug, err := shared.NewSlug(s) +func mustCode(s string) shared.Code { + code, err := shared.NewCode(s) if err != nil { panic(err) } - return slug + return code } -func mustModelID(s string) shared.ModelID { - id, err := shared.NewModelID(s) +func mustModelCode(s string) shared.ModelCode { + id, err := shared.NewModelCode(s) if err != nil { panic(err) } @@ -70,9 +70,9 @@ func TestProjectionUpsertAndGet(t *testing.T) { sum := conversation.SessionSummary{ ID: shared.NewID(), Title: "test session", - AgentSlug: mustSlug("coder"), - LastProviderSlug: mustSlug("anthropic"), - LastModelSlug: mustModelID("claude-opus"), + AgentCode: mustCode("coder"), + LastProviderCode: mustCode("anthropic"), + LastModelCode: mustModelCode("claude-opus"), ContextWindow: 1024, LastReasoningEffort: shared.ReasoningHigh, CreatedAt: time.Now().UTC().Truncate(time.Second), @@ -95,8 +95,8 @@ func TestProjectionUpsertAndGet(t *testing.T) { if got.Title != sum.Title { t.Errorf("Title = %q, want %q", got.Title, sum.Title) } - if got.AgentSlug != sum.AgentSlug { - t.Errorf("AgentSlug = %q, want %q", got.AgentSlug, sum.AgentSlug) + if got.AgentCode != sum.AgentCode { + t.Errorf("AgentCode = %q, want %q", got.AgentCode, sum.AgentCode) } if got.ContextWindow != sum.ContextWindow { t.Errorf("ContextWindow = %d, want %d", got.ContextWindow, sum.ContextWindow) @@ -112,7 +112,7 @@ func TestProjectionUpsertUpdatesExisting(t *testing.T) { sum := conversation.SessionSummary{ ID: shared.NewID(), Title: "original", - AgentSlug: mustSlug("coder"), + AgentCode: mustCode("coder"), CreatedAt: time.Now().UTC().Truncate(time.Second), UpdatedAt: time.Now().UTC().Truncate(time.Second), } @@ -154,8 +154,8 @@ func TestProjectionGetReturnsNotFound(t *testing.T) { func TestProjectionList(t *testing.T) { repo := newConversationRepo(t) ctx := context.Background() - agentA := mustSlug("agent-a") - agentB := mustSlug("agent-b") + agentA := mustCode("agent-a") + agentB := mustCode("agent-b") baseTime := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { @@ -166,7 +166,7 @@ func TestProjectionList(t *testing.T) { sum := conversation.SessionSummary{ ID: shared.NewID(), Title: "session", - AgentSlug: agent, + AgentCode: agent, CreatedAt: baseTime, UpdatedAt: baseTime.Add(time.Duration(i) * time.Second), } @@ -183,7 +183,7 @@ func TestProjectionList(t *testing.T) { t.Errorf("List all returned %d, want 5", len(all)) } - filtered, err := repo.listSessions(ctx, conversation.ListQuery{AgentSlug: &agentA}) + filtered, err := repo.listSessions(ctx, conversation.ListQuery{AgentCode: &agentA}) if err != nil { t.Fatalf("List filtered: %v", err) } @@ -191,8 +191,8 @@ func TestProjectionList(t *testing.T) { t.Errorf("List filtered returned %d, want 2", len(filtered)) } for _, s := range filtered { - if s.AgentSlug != agentA { - t.Errorf("expected only agent-a, got %s", s.AgentSlug) + if s.AgentCode != agentA { + t.Errorf("expected only agent-a, got %s", s.AgentCode) } } @@ -236,7 +236,7 @@ func TestProjectionDelete(t *testing.T) { ctx := context.Background() sum := conversation.SessionSummary{ ID: shared.NewID(), - AgentSlug: mustSlug("coder"), + AgentCode: mustCode("coder"), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } @@ -261,10 +261,10 @@ func TestTranscriptAppendAndLoad(t *testing.T) { sessionID := shared.NewID() createdAt := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) - agentSlug := mustSlug("coder") + agentCode := mustCode("coder") events := []shared.Event{ - conversation.SessionStarted{SessionID: sessionID, Agent: agentSlug, Model: shared.NewModelRef("anthropic", "claude-opus"), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, + conversation.SessionStarted{SessionID: sessionID, Agent: agentCode, Model: shared.NewModelRef("anthropic", "claude-opus"), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, conversation.RoundStarted{SessionID: sessionID, RoundID: shared.NewID(), Sequence: 1, Model: shared.NewModelRef("anthropic", "claude-opus"), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, } @@ -294,10 +294,10 @@ func TestTranscriptAppendIsAppendOnly(t *testing.T) { sessionID := shared.NewID() createdAt := time.Now().UTC() - agentSlug := mustSlug("coder") + agentCode := mustCode("coder") first := []shared.Event{ - conversation.SessionStarted{SessionID: sessionID, Agent: agentSlug, Model: shared.NewModelRef("anthropic", "claude-opus"), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, + conversation.SessionStarted{SessionID: sessionID, Agent: agentCode, Model: shared.NewModelRef("anthropic", "claude-opus"), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, } second := []shared.Event{ conversation.RoundStarted{SessionID: sessionID, RoundID: shared.NewID(), Sequence: 1, Model: shared.NewModelRef("anthropic", "claude-opus"), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, @@ -328,7 +328,7 @@ func TestTranscriptLoadsLargeMessage(t *testing.T) { createdAt := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) largeText := strings.Repeat("x", 128*1024) events := []shared.Event{ - conversation.SessionStarted{SessionID: sessionID, Agent: mustSlug("coder"), Model: defaultModel(), At: createdAt}, + conversation.SessionStarted{SessionID: sessionID, Agent: mustCode("coder"), Model: defaultModel(), At: createdAt}, conversation.RoundStarted{SessionID: sessionID, RoundID: roundID, Sequence: 1, Model: defaultModel(), At: createdAt}, conversation.MessageAppended{SessionID: sessionID, Message: conversation.Message{ID: shared.NewID(), RoundID: roundID, Role: conversation.RoleUser, Content: conversation.Text(largeText), CreatedAt: createdAt}, At: createdAt}, } @@ -359,7 +359,7 @@ func TestTranscriptReportsCorruptLine(t *testing.T) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } - valid, err := shared.EncodeEvent(1, conversation.SessionStarted{SessionID: sessionID, Agent: mustSlug("coder"), Model: defaultModel(), At: createdAt}) + valid, err := shared.EncodeEvent(1, conversation.SessionStarted{SessionID: sessionID, Agent: mustCode("coder"), Model: defaultModel(), At: createdAt}) if err != nil { t.Fatal(err) } @@ -388,7 +388,7 @@ func TestTranscriptDelete(t *testing.T) { sessionID := shared.NewID() createdAt := time.Now().UTC() events := []shared.Event{ - conversation.SessionStarted{SessionID: sessionID, Agent: mustSlug("coder"), Model: defaultModel(), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, + conversation.SessionStarted{SessionID: sessionID, Agent: mustCode("coder"), Model: defaultModel(), ContextWindow: 200_000, ReasoningEffort: shared.ReasoningOff, At: createdAt}, } if err := repo.appendTranscript(sessionID, createdAt, 1, events); err != nil { @@ -436,7 +436,7 @@ func TestConversationSaveAndLoad(t *testing.T) { ctx := context.Background() // Start a session, add a round and messages. - session := conversation.StartSession(mustSlug("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) + session := conversation.StartSession(mustCode("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) roundID, err := session.StartRound() if err != nil { t.Fatal(err) @@ -481,7 +481,7 @@ func TestConversationSaveAndLoad(t *testing.T) { func TestConversationSaveWithCanceledContextHasNoSideEffects(t *testing.T) { repo := newConversationRepo(t) - session := conversation.StartSession(mustSlug("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) + session := conversation.StartSession(mustCode("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -504,7 +504,7 @@ func TestConversationSaveAppendsEvents(t *testing.T) { repo := newConversationRepo(t) ctx := context.Background() - session := conversation.StartSession(mustSlug("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) + session := conversation.StartSession(mustCode("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) if err := repo.Save(ctx, session); err != nil { t.Fatal(err) } @@ -537,8 +537,8 @@ func TestConversationList(t *testing.T) { repo := newConversationRepo(t) ctx := context.Background() - agentA := mustSlug("agent-a") - agentB := mustSlug("agent-b") + agentA := mustCode("agent-a") + agentB := mustCode("agent-b") for i := 0; i < 3; i++ { agent := agentA @@ -559,7 +559,7 @@ func TestConversationList(t *testing.T) { t.Errorf("List all returned %d, want 3", len(all)) } - filtered, err := repo.List(ctx, conversation.ListQuery{AgentSlug: &agentA}) + filtered, err := repo.List(ctx, conversation.ListQuery{AgentCode: &agentA}) if err != nil { t.Fatalf("List filtered: %v", err) } @@ -572,7 +572,7 @@ func TestConversationDelete(t *testing.T) { repo := newConversationRepo(t) ctx := context.Background() - session := conversation.StartSession(mustSlug("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) + session := conversation.StartSession(mustCode("coder"), defaultModel(), 200_000, shared.ReasoningOff, nil) if err := repo.Save(ctx, session); err != nil { t.Fatal(err) } diff --git a/packages/agenty-core/pkg/infra/storage/db.go b/packages/agenty-core/pkg/infra/storage/db.go index 36eefe8..691b09e 100644 --- a/packages/agenty-core/pkg/infra/storage/db.go +++ b/packages/agenty-core/pkg/infra/storage/db.go @@ -10,16 +10,16 @@ const schema = ` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY NOT NULL, title TEXT NOT NULL DEFAULT '', - agent_slug TEXT NOT NULL, - last_provider_slug TEXT NOT NULL DEFAULT '', - last_model_slug TEXT NOT NULL DEFAULT '', + agent_code TEXT NOT NULL, + last_provider_code TEXT NOT NULL DEFAULT '', + last_model_code TEXT NOT NULL DEFAULT '', context_window INTEGER NOT NULL DEFAULT 0, last_reasoning_effort TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_sessions_agent_slug ON sessions(agent_slug); +CREATE INDEX IF NOT EXISTS idx_sessions_agent_code ON sessions(agent_code); CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at DESC); ` diff --git a/packages/agenty-core/pkg/infra/storage/db_test.go b/packages/agenty-core/pkg/infra/storage/db_test.go index f54b538..296d637 100644 --- a/packages/agenty-core/pkg/infra/storage/db_test.go +++ b/packages/agenty-core/pkg/infra/storage/db_test.go @@ -15,8 +15,8 @@ func TestOpenDBInitializesSchema(t *testing.T) { t.Cleanup(func() { _ = db.Close() }) wantColumns := map[string]bool{ - "id": false, "title": false, "agent_slug": false, - "last_provider_slug": false, "last_model_slug": false, + "id": false, "title": false, "agent_code": false, + "last_provider_code": false, "last_model_code": false, "context_window": false, "last_reasoning_effort": false, "created_at": false, "updated_at": false, } diff --git a/packages/agenty-core/pkg/utils/apply_diff.go b/packages/agenty-core/pkg/utils/apply_diff.go new file mode 100644 index 0000000..b3d186d --- /dev/null +++ b/packages/agenty-core/pkg/utils/apply_diff.go @@ -0,0 +1,393 @@ +package utils + +import ( + "fmt" + "strings" + "unicode" +) + +type ApplyDiffMode uint8 + +const ( + ApplyDiffDefault ApplyDiffMode = iota + ApplyDiffCreate +) + +type applyDiffChunk struct { + originalIndex int + deletedLines []string + insertedLines []string +} + +type applyDiffParser struct { + lines []string + index int + fuzz int +} + +const ( + applyDiffEndPatch = "*** End Patch" + applyDiffEndFile = "*** End of File" +) + +var applyDiffSectionMarkers = []string{ + applyDiffEndPatch, + "*** Update File:", + "*** Delete File:", + "*** Add File:", + applyDiffEndFile, +} + +var applyDiffSectionTerminators = []string{ + applyDiffEndPatch, + "*** Update File:", + "*** Delete File:", + "*** Add File:", +} + +// ApplyDiff applies a headerless V4A diff using the OpenAI Agents SDK semantics. +func ApplyDiff(input, diff string, mode ApplyDiffMode) (string, error) { + diffLines := normalizeApplyDiffLines(diff) + switch mode { + case ApplyDiffCreate: + return parseCreateDiff(diffLines) + case ApplyDiffDefault: + default: + return "", fmt.Errorf("apply diff: unsupported mode %d", mode) + } + + chunks, err := parseUpdateDiff(diffLines, input) + if err != nil { + return "", err + } + return applyDiffChunks(input, chunks) +} + +func normalizeApplyDiffLines(diff string) []string { + lines := strings.Split(strings.ReplaceAll(diff, "\r\n", "\n"), "\n") + for index := range lines { + lines[index] = strings.TrimSuffix(lines[index], "\r") + } + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func parseCreateDiff(lines []string) (string, error) { + parser := applyDiffParser{ + lines: append(append([]string{}, lines...), applyDiffEndPatch), + } + output := make([]string, 0, len(lines)) + for !parser.done(applyDiffSectionTerminators) { + line := parser.lines[parser.index] + parser.index++ + if !strings.HasPrefix(line, "+") { + return "", fmt.Errorf("invalid add file line: %s", line) + } + output = append(output, strings.TrimPrefix(line, "+")) + } + return strings.Join(output, "\n"), nil +} + +func parseUpdateDiff(lines []string, input string) ([]applyDiffChunk, error) { + parser := applyDiffParser{ + lines: append(append([]string{}, lines...), applyDiffEndPatch), + } + inputLines := strings.Split(input, "\n") + chunks := make([]applyDiffChunk, 0) + cursor := 0 + + for !parser.done(applyDiffSectionMarkers) { + anchors, anchorCount := parser.readAnchors() + if anchorCount == 0 && cursor != 0 { + return nil, fmt.Errorf("invalid line:\n%s", parser.lines[parser.index]) + } + + requireAnchorMatch := anchorCount > 1 + for index, anchor := range anchors { + var err error + cursor, err = parser.advanceCursorToAnchor( + anchor, + inputLines, + cursor, + requireAnchorMatch, + index > 0, + ) + if err != nil { + return nil, err + } + } + + context, sectionChunks, endIndex, eof, err := readApplyDiffSection(parser.lines, parser.index) + if err != nil { + return nil, err + } + newIndex, fuzz := findApplyDiffContext(inputLines, context, cursor, eof) + if newIndex == -1 { + contextText := strings.Join(context, "\n") + if eof { + return nil, fmt.Errorf("invalid EOF context %d:\n%s", cursor, contextText) + } + return nil, fmt.Errorf("invalid context %d:\n%s", cursor, contextText) + } + + parser.fuzz += fuzz + for _, chunk := range sectionChunks { + chunk.originalIndex += newIndex + chunks = append(chunks, chunk) + } + cursor = newIndex + len(context) + parser.index = endIndex + } + + return chunks, nil +} + +func (parser *applyDiffParser) done(prefixes []string) bool { + if parser.index >= len(parser.lines) { + return true + } + for _, prefix := range prefixes { + if strings.HasPrefix(parser.lines[parser.index], prefix) { + return true + } + } + return false +} + +func (parser *applyDiffParser) readAnchors() ([]string, int) { + anchors := make([]string, 0) + anchorCount := 0 + for { + line := parser.lines[parser.index] + switch { + case strings.HasPrefix(line, "@@ "): + parser.index++ + anchorCount++ + anchor := strings.TrimPrefix(line, "@@ ") + if strings.TrimSpace(anchor) != "" { + anchors = append(anchors, anchor) + } + case line == "@@": + parser.index++ + anchorCount++ + default: + return anchors, anchorCount + } + } +} + +func (parser *applyDiffParser) advanceCursorToAnchor( + anchor string, + inputLines []string, + cursor int, + requireMatch bool, + forceForwardSearch bool, +) (int, error) { + found := false + if !forceForwardSearch && containsApplyDiffLine(inputLines[:cursor], anchor, false) { + found = true + } else if index := findApplyDiffLine(inputLines, anchor, cursor, false); index >= 0 { + cursor = index + 1 + found = true + } + + if !found { + if !forceForwardSearch && containsApplyDiffLine(inputLines[:cursor], anchor, true) { + found = true + } else if index := findApplyDiffLine(inputLines, anchor, cursor, true); index >= 0 { + cursor = index + 1 + parser.fuzz++ + found = true + } + } + + if requireMatch && !found { + return 0, fmt.Errorf("invalid anchor %d:\n%s", cursor, anchor) + } + return cursor, nil +} + +func containsApplyDiffLine(lines []string, target string, trimmed bool) bool { + return findApplyDiffLine(lines, target, 0, trimmed) >= 0 +} + +func findApplyDiffLine(lines []string, target string, start int, trimmed bool) int { + for index := start; index < len(lines); index++ { + line := lines[index] + if trimmed { + line = strings.TrimSpace(line) + target = strings.TrimSpace(target) + } + if line == target { + return index + } + } + return -1 +} + +func readApplyDiffSection( + lines []string, + startIndex int, +) ([]string, []applyDiffChunk, int, bool, error) { + context := make([]string, 0) + deletedLines := make([]string, 0) + insertedLines := make([]string, 0) + chunks := make([]applyDiffChunk, 0) + mode := byte(' ') + index := startIndex + + flushChunk := func() { + if len(insertedLines) == 0 && len(deletedLines) == 0 { + return + } + chunks = append(chunks, applyDiffChunk{ + originalIndex: len(context) - len(deletedLines), + deletedLines: deletedLines, + insertedLines: insertedLines, + }) + deletedLines = make([]string, 0) + insertedLines = make([]string, 0) + } + + for index < len(lines) { + raw := lines[index] + if strings.HasPrefix(raw, "@@") || isApplyDiffSectionEnd(raw) { + break + } + if raw == "***" { + break + } + if strings.HasPrefix(raw, "***") { + return nil, nil, 0, false, fmt.Errorf("invalid line: %s", raw) + } + + index++ + previousMode := mode + line := raw + if line == "" { + line = " " + } + switch line[0] { + case '+', '-', ' ': + mode = line[0] + default: + return nil, nil, 0, false, fmt.Errorf("invalid line: %s", line) + } + line = line[1:] + + if mode == ' ' && previousMode != mode { + flushChunk() + } + switch mode { + case '-': + deletedLines = append(deletedLines, line) + context = append(context, line) + case '+': + insertedLines = append(insertedLines, line) + case ' ': + context = append(context, line) + } + } + flushChunk() + + if index < len(lines) && lines[index] == applyDiffEndFile { + return context, chunks, index + 1, true, nil + } + if index == startIndex { + return nil, nil, 0, false, fmt.Errorf("nothing in section at index %d: %s", index, lines[index]) + } + return context, chunks, index, false, nil +} + +func isApplyDiffSectionEnd(line string) bool { + for _, marker := range applyDiffSectionMarkers { + if strings.HasPrefix(line, marker) { + return true + } + } + return false +} + +func findApplyDiffContext(lines, context []string, start int, eof bool) (int, int) { + if eof { + endStart := max(0, len(lines)-len(context)) + if index, fuzz := findApplyDiffContextCore(lines, context, endStart); index != -1 { + return index, fuzz + } + index, fuzz := findApplyDiffContextCore(lines, context, start) + return index, fuzz + 10_000 + } + return findApplyDiffContextCore(lines, context, start) +} + +func findApplyDiffContextCore(lines, context []string, start int) (int, int) { + if len(context) == 0 { + return start, 0 + } + + comparisons := []struct { + fuzz int + mapf func(string) string + }{ + {fuzz: 0, mapf: func(value string) string { return value }}, + {fuzz: 1, mapf: func(value string) string { + return strings.TrimRightFunc(value, unicode.IsSpace) + }}, + {fuzz: 100, mapf: strings.TrimSpace}, + } + for _, comparison := range comparisons { + for index := start; index < len(lines); index++ { + if equalApplyDiffSlice(lines, context, index, comparison.mapf) { + return index, comparison.fuzz + } + } + } + return -1, 0 +} + +func equalApplyDiffSlice( + source []string, + target []string, + start int, + mapf func(string) string, +) bool { + if start+len(target) > len(source) { + return false + } + for index := range target { + if mapf(source[start+index]) != mapf(target[index]) { + return false + } + } + return true +} + +func applyDiffChunks(input string, chunks []applyDiffChunk) (string, error) { + originalLines := strings.Split(input, "\n") + destinationLines := make([]string, 0, len(originalLines)) + originalIndex := 0 + for _, chunk := range chunks { + if chunk.originalIndex > len(originalLines) { + return "", fmt.Errorf( + "applyDiff: chunk original index %d exceeds input length %d", + chunk.originalIndex, + len(originalLines), + ) + } + if originalIndex > chunk.originalIndex { + return "", fmt.Errorf( + "applyDiff: overlapping chunk at %d with cursor %d", + chunk.originalIndex, + originalIndex, + ) + } + + destinationLines = append(destinationLines, originalLines[originalIndex:chunk.originalIndex]...) + destinationLines = append(destinationLines, chunk.insertedLines...) + originalIndex = chunk.originalIndex + len(chunk.deletedLines) + } + destinationLines = append(destinationLines, originalLines[originalIndex:]...) + return strings.Join(destinationLines, "\n"), nil +} diff --git a/packages/agenty-core/pkg/utils/apply_diff_test.go b/packages/agenty-core/pkg/utils/apply_diff_test.go new file mode 100644 index 0000000..ef3c447 --- /dev/null +++ b/packages/agenty-core/pkg/utils/apply_diff_test.go @@ -0,0 +1,258 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestApplyDiff(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + diff string + mode ApplyDiffMode + want string + }{ + { + name: "create file with blank line", + diff: "+hello\n+world\n+", + mode: ApplyDiffCreate, + want: "hello\nworld\n", + }, + { + name: "create empty file", + mode: ApplyDiffCreate, + want: "", + }, + { + name: "create file normalizes CRLF", + diff: "+hello\r\n+\r\n+world\r\n", + mode: ApplyDiffCreate, + want: "hello\n\nworld", + }, + { + name: "empty diff preserves existing file", + input: "one\ntwo\n", + want: "one\ntwo\n", + }, + { + name: "floating insertion into empty file", + input: "", + diff: "@@\n+hello\n+world", + want: "hello\nworld\n", + }, + { + name: "floating hunk", + input: "- Milk\n- Bread\n- Eggs\n- Apples\n- Coffee", + diff: "@@\n - Milk\n - Bread\n - Eggs\n-- Apples\n-- Coffee\n+- [x] Apples\n+- [x] Coffee", + want: "- Milk\n- Bread\n- Eggs\n- [x] Apples\n- [x] Coffee", + }, + { + name: "anchored replacement preserves trailing newline", + input: "line1\nline2\nline3\n", + diff: "@@ line1\n-line2\n+updated\n line3", + want: "line1\nupdated\nline3\n", + }, + { + name: "deletion with context", + input: "keep\nremove me\nstay\n", + diff: "@@ keep\n-remove me\n stay", + want: "keep\nstay\n", + }, + { + name: "pure insertion with blank context lines", + input: "import os\n\ndef main():\n return 1\n", + diff: " import os\n+import sys\n\n def main():\n return 1", + want: "import os\nimport sys\n\ndef main():\n return 1\n", + }, + { + name: "multiple anchored sections", + input: "class Foo:\n def baz(self):\n return 1\n\ndef main():\n print(Foo().baz())\n", + diff: "@@ class Foo:\n- def baz(self):\n+ def value(self):\n return 1\n@@ def main():\n- print(Foo().baz())\n+ print(Foo().value())", + want: "class Foo:\n def value(self):\n return 1\n\ndef main():\n print(Foo().value())\n", + }, + { + name: "stacked anchors", + input: "class First\n def target():\n pass\n\nclass Second\n def target():\n pass\n", + diff: "@@ class Second\n@@ def target():\n- pass\n+ return 1", + want: "class First\n def target():\n pass\n\nclass Second\n def target():\n return 1\n", + }, + { + name: "reuses parent anchor in a later hunk", + input: "class Target\n def first():\n pass\n\n def second():\n pass\n", + diff: "@@ class Target\n@@ def first():\n- pass\n+ return 1\n@@ class Target\n@@ def second():\n- pass\n+ return 2", + want: "class Target\n def first():\n return 1\n\n def second():\n return 2\n", + }, + { + name: "reuses trimmed parent anchor in a later hunk", + input: " class Target \n def first():\n pass\n\n def second():\n pass\n", + diff: "@@ class Target\n@@ def first():\n- pass\n+ return 1\n@@ class Target\n@@ def second():\n- pass\n+ return 2", + want: " class Target \n def first():\n return 1\n\n def second():\n return 2\n", + }, + { + name: "single missing anchor remains best effort", + input: "one\ntwo\n", + diff: "@@ missing\n-one\n+first", + want: "first\ntwo\n", + }, + { + name: "trailing bare anchor", + input: "class Only\n def run():\n pass\n", + diff: "@@ class Only\n@@\n- pass\n+ return 1", + want: "class Only\n def run():\n return 1\n", + }, + { + name: "end of file", + input: "Line A\nLine B\nLine C", + diff: "@@\n Line B\n-Line C\n+Line C updated\n*** End of File", + want: "Line A\nLine B\nLine C updated", + }, + { + name: "trailing whitespace fuzz", + input: "one \ntwo\n", + diff: " one\n-two\n+second", + want: "one \nsecond\n", + }, + { + name: "leading and trailing whitespace fuzz", + input: " target \nnext\n", + diff: " target\n-next\n+done", + want: " target \ndone\n", + }, + { + name: "traditional line marker is a best effort anchor", + input: "one\ntwo\n", + diff: "@@ -1,2 +1,2 @@\n one\n-two\n+2", + want: "one\n2\n", + }, + { + name: "update diff normalizes CRLF", + input: "one\ntwo\n", + diff: " one\r\n-two\r\n+second\r\n", + want: "one\nsecond\n", + }, + { + name: "end of file marker falls back to earlier context", + input: "target\nmiddle\nend", + diff: " target\n+after\n*** End of File", + want: "target\nafter\nmiddle\nend", + }, + { + name: "context-only diff leaves content unchanged", + input: "legacy content", + diff: " legacy content", + want: "legacy content", + }, + { + name: "replacement works without hunk marker", + input: "before\nkeep", + diff: "-before\n+after", + want: "after\nkeep", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := ApplyDiff(test.input, test.diff, test.mode) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Errorf("ApplyDiff() = %q, want %q", got, test.want) + } + }) + } +} + +func TestApplyDiffRejectsInvalidInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + diff string + mode ApplyDiffMode + wantErr string + }{ + { + name: "unsupported mode", + mode: ApplyDiffMode(255), + wantErr: "unsupported mode 255", + }, + { + name: "create line without plus", + diff: "+valid\ninvalid", + mode: ApplyDiffCreate, + wantErr: "invalid add file line", + }, + { + name: "missing context", + input: "one\ntwo\n", + diff: " missing\n-two\n+second", + wantErr: "invalid context", + }, + { + name: "missing first stacked anchor", + input: "class Wrong\n def desired():\n pass\n", + diff: "@@ class Target\n@@ def desired():\n- pass\n+ return 1", + wantErr: "invalid anchor", + }, + { + name: "missing second stacked anchor", + input: "class Target\n def desired():\n pass\n", + diff: "@@ class Target\n@@ def missing():\n- pass\n+ return 1", + wantErr: "invalid anchor", + }, + { + name: "missing anchor followed by bare marker", + input: "one\ntwo\n", + diff: "@@ missing\n@@\n-two\n+second", + wantErr: "invalid anchor", + }, + { + name: "invalid unprefixed update line", + input: "one\n", + diff: "one", + wantErr: "invalid line", + }, + { + name: "unknown patch directive", + input: "one\n", + diff: "*** Unknown Directive", + wantErr: "invalid line", + }, + { + name: "empty section", + input: "one\n", + diff: "@@", + wantErr: "nothing in section", + }, + { + name: "invalid EOF context", + input: "one\ntwo", + diff: " missing\n*** End of File", + wantErr: "invalid EOF context", + }, + { + name: "content after EOF marker needs another anchor", + input: "one", + diff: " one\n*** End of File\n two", + wantErr: "invalid line", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := ApplyDiff(test.input, test.diff, test.mode) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ApplyDiff() error = %v, want containing %q", err, test.wantErr) + } + }) + } +} diff --git a/packages/agenty-core/test/e2e/agenty_client_test.go b/packages/agenty-core/test/e2e/agenty_client_test.go index c9aaf7c..82db530 100644 --- a/packages/agenty-core/test/e2e/agenty_client_test.go +++ b/packages/agenty-core/test/e2e/agenty_client_test.go @@ -22,12 +22,12 @@ func (c *agentyClient) InitializeAlready(ctx context.Context) (InitializeResult, func (c *agentyClient) CompleteInitialization( ctx context.Context, - agentSlug, providerSlug, modelSlug string, + agentCode, providerCode, modelCode string, ) (InitializeResult, error) { return callResult[InitializeResult](ctx, c.rpc, "initialize.complete", map[string]any{ - "agentSlug": agentSlug, - "providerSlug": providerSlug, - "modelSlug": modelSlug, + "agentCode": agentCode, + "providerCode": providerCode, + "modelCode": modelCode, }) } @@ -40,12 +40,12 @@ func (c *agentyClient) CreateAgent(ctx context.Context, input AgentCreateInput) ) } -func (c *agentyClient) GetAgent(ctx context.Context, slug string) (Agent, error) { +func (c *agentyClient) GetAgent(ctx context.Context, code string) (Agent, error) { return callResult[Agent]( ctx, c.rpc, "agent.get", - map[string]any{"slug": slug}, + map[string]any{"code": code}, ) } @@ -67,12 +67,12 @@ func (c *agentyClient) UpdateAgent(ctx context.Context, input AgentUpdateInput) ) } -func (c *agentyClient) DeleteAgent(ctx context.Context, slug string) (DeleteResult, error) { +func (c *agentyClient) DeleteAgent(ctx context.Context, code string) (DeleteResult, error) { return callResult[DeleteResult]( ctx, c.rpc, "agent.delete", - map[string]any{"slug": slug}, + map[string]any{"code": code}, ) } @@ -85,12 +85,12 @@ func (c *agentyClient) CreateProvider(ctx context.Context, input ProviderCreateI ) } -func (c *agentyClient) GetProvider(ctx context.Context, slug string) (Provider, error) { +func (c *agentyClient) GetProvider(ctx context.Context, code string) (Provider, error) { return callResult[Provider]( ctx, c.rpc, "provider.get", - map[string]any{"slug": slug}, + map[string]any{"code": code}, ) } @@ -112,12 +112,12 @@ func (c *agentyClient) UpdateProvider(ctx context.Context, input ProviderUpdateI ) } -func (c *agentyClient) DeleteProvider(ctx context.Context, slug string) (DeleteResult, error) { +func (c *agentyClient) DeleteProvider(ctx context.Context, code string) (DeleteResult, error) { return callResult[DeleteResult]( ctx, c.rpc, "provider.delete", - map[string]any{"slug": slug}, + map[string]any{"code": code}, ) } @@ -130,14 +130,14 @@ func (c *agentyClient) AddModel(ctx context.Context, input ModelInput) (Provider ) } -func (c *agentyClient) RemoveModel(ctx context.Context, providerSlug, modelSlug string) (Provider, error) { +func (c *agentyClient) RemoveModel(ctx context.Context, providerCode, modelCode string) (Provider, error) { return callResult[Provider]( ctx, c.rpc, "provider.removeModel", map[string]any{ - "providerSlug": providerSlug, - "modelSlug": modelSlug, + "providerCode": providerCode, + "modelCode": modelCode, }, ) } @@ -201,8 +201,8 @@ func (c *agentyClient) SetSessionModel( "session.setModel", map[string]any{ "id": id, - "providerSlug": model.ProviderSlug, - "modelSlug": model.ModelSlug, + "providerCode": model.ProviderCode, + "modelCode": model.ModelCode, }, ) } diff --git a/packages/agenty-core/test/e2e/contracts_test.go b/packages/agenty-core/test/e2e/contracts_test.go index 8f8f21b..b51e1f4 100644 --- a/packages/agenty-core/test/e2e/contracts_test.go +++ b/packages/agenty-core/test/e2e/contracts_test.go @@ -81,8 +81,8 @@ func (e *RPCError) Error() string { } type ModelRef struct { - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` } type InitializeResult struct { @@ -112,7 +112,7 @@ type StreamEvent struct { } type Agent struct { - Slug string `json:"slug"` + Code string `json:"code"` Name string `json:"name"` Description string `json:"description"` Soul string `json:"soul"` @@ -126,7 +126,7 @@ type Agent struct { } type Model struct { - Slug string `json:"slug"` + Code string `json:"code"` Name string `json:"name"` ContextWindow int `json:"contextWindow"` MaxOutputTokens int64 `json:"maxOutputTokens"` @@ -137,7 +137,7 @@ type Model struct { } type Provider struct { - Slug string `json:"slug"` + Code string `json:"code"` Name string `json:"name"` Type string `json:"type"` BaseURL string `json:"baseUrl"` @@ -148,7 +148,7 @@ type Provider struct { type Session struct { ID string `json:"id"` - AgentSlug string `json:"agentSlug"` + AgentCode string `json:"agentCode"` Title *string `json:"title"` Cwd *string `json:"cwd"` CurrentModel *ModelRef `json:"currentModel"` @@ -162,9 +162,9 @@ type Session struct { type SessionSummary struct { ID string `json:"id"` Title string `json:"title"` - AgentSlug string `json:"agentSlug"` - LastProviderSlug string `json:"lastProviderSlug"` - LastModelSlug string `json:"lastModelSlug"` + AgentCode string `json:"agentCode"` + LastProviderCode string `json:"lastProviderCode"` + LastModelCode string `json:"lastModelCode"` ContextWindow int64 `json:"contextWindow"` LastReasoningEffort string `json:"lastReasoningEffort"` } @@ -222,13 +222,13 @@ type ExecutionStop struct { } type DeleteResult struct { - Slug string `json:"slug,omitempty"` + Code string `json:"code,omitempty"` ID string `json:"id,omitempty"` Deleted bool `json:"deleted"` } type AgentCreateInput struct { - Slug string `json:"slug"` + Code string `json:"code"` Name string `json:"name"` Description string `json:"description,omitempty"` Soul string `json:"soul,omitempty"` @@ -240,7 +240,7 @@ type AgentCreateInput struct { } type AgentUpdateInput struct { - Slug string `json:"slug"` + Code string `json:"code"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Soul *string `json:"soul,omitempty"` @@ -248,7 +248,7 @@ type AgentUpdateInput struct { } type ProviderCreateInput struct { - Slug string `json:"slug"` + Code string `json:"code"` Name string `json:"name"` Type string `json:"type"` BaseURL string `json:"baseUrl,omitempty"` @@ -257,7 +257,7 @@ type ProviderCreateInput struct { } type ProviderUpdateInput struct { - Slug string `json:"slug"` + Code string `json:"code"` Name *string `json:"name,omitempty"` Type *string `json:"type,omitempty"` BaseURL *string `json:"baseUrl,omitempty"` @@ -266,8 +266,8 @@ type ProviderUpdateInput struct { } type ModelInput struct { - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` Name string `json:"name"` ContextWindow int `json:"contextWindow,omitempty"` MaxOutputTokens int64 `json:"maxOutputTokens"` @@ -278,16 +278,16 @@ type ModelInput struct { } type SessionCreateInput struct { - AgentSlug string `json:"agentSlug"` - ProviderSlug string `json:"providerSlug"` - ModelSlug string `json:"modelSlug"` + AgentCode string `json:"agentCode"` + ProviderCode string `json:"providerCode"` + ModelCode string `json:"modelCode"` ContextWindow int64 `json:"contextWindow,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` Cwd *string `json:"cwd,omitempty"` } type SessionListInput struct { - AgentSlug string `json:"agentSlug,omitempty"` + AgentCode string `json:"agentCode,omitempty"` Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` } diff --git a/packages/agenty-core/test/e2e/execution_test.go b/packages/agenty-core/test/e2e/execution_test.go index 8c73aae..7d06679 100644 --- a/packages/agenty-core/test/e2e/execution_test.go +++ b/packages/agenty-core/test/e2e/execution_test.go @@ -201,14 +201,10 @@ func TestAgentLoopExecutesThroughEveryProviderProtocol(t *testing.T) { ) } wantTools := []string{ - "delete_file", - "glob", - "grep", - "ls", - "patch_file", - "read_file", - "shell", - "write_file", + "delete_file", "glob", "grep", "ls", "patch_file", "read_file", "shell", "write_file", + } + if tt.apiType == "openai" { + wantTools = []string{"apply_patch", "glob", "grep", "ls", "read_file", "shell"} } if names := providerToolNames(request, tt.apiType); !slices.Equal(names, wantTools) { t.Errorf("provider tools = %q, want %q", names, wantTools) @@ -245,9 +241,9 @@ func TestSingleIPCClientRunsSessionsConcurrently(t *testing.T) { ) requireNoError(t, err) second, err := client.CreateSession(ctx, SessionCreateInput{ - AgentSlug: "parallel-agent", - ProviderSlug: "parallel-provider", - ModelSlug: "parallel-model", + AgentCode: "parallel-agent", + ProviderCode: "parallel-provider", + ModelCode: "parallel-model", ContextWindow: 128_000, }) requireNoError(t, err) diff --git a/packages/agenty-core/test/e2e/journey_test.go b/packages/agenty-core/test/e2e/journey_test.go index c257e72..2a54eb0 100644 --- a/packages/agenty-core/test/e2e/journey_test.go +++ b/packages/agenty-core/test/e2e/journey_test.go @@ -28,7 +28,7 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { t.Fatal("fresh data dir reported initialized") } _, err = first.CreateProvider(ctx, ProviderCreateInput{ - Slug: "setup-provider", + Code: "setup-provider", Name: "Setup Provider", Type: "openai_completions", BaseURL: fixture.BaseURL("openai_completions"), @@ -37,8 +37,8 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { }) requireNoError(t, err) _, err = first.AddModel(ctx, ModelInput{ - ProviderSlug: "setup-provider", - ModelSlug: "setup-model", + ProviderCode: "setup-provider", + ModelCode: "setup-model", Name: "Setup Model", ContextWindow: 64_000, MaxOutputTokens: 8_192, @@ -46,9 +46,9 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { }) requireNoError(t, err) _, err = first.CreateAgent(ctx, AgentCreateInput{ - Slug: "setup-agent", + Code: "setup-agent", Name: "Setup Agent", - DefaultModel: &ModelRef{ProviderSlug: "setup-provider", ModelSlug: "setup-model"}, + DefaultModel: &ModelRef{ProviderCode: "setup-provider", ModelCode: "setup-model"}, DefaultContextWindow: 64_000, IsDefault: true, }) @@ -64,25 +64,25 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { requireNoError(t, err) createdAgent, err := first.CreateAgent(ctx, AgentCreateInput{ - Slug: "daily-assistant", + Code: "daily-assistant", Name: "Daily Assistant", Description: "Helps with daily work", Soul: "Be concise and verify facts.", - DefaultModel: &ModelRef{ProviderSlug: "local-openai", ModelSlug: "primary-model"}, + DefaultModel: &ModelRef{ProviderCode: "local-openai", ModelCode: "primary-model"}, DefaultContextWindow: 128_000, DefaultReasoningEffort: "high", IsDefault: true, Metadata: map[string]any{"team": "platform"}, }) requireNoError(t, err) - if createdAgent.Slug != "daily-assistant" || createdAgent.CreatedAt.IsZero() { + if createdAgent.Code != "daily-assistant" || createdAgent.CreatedAt.IsZero() { t.Fatalf("created agent = %+v", createdAgent) } - _, err = first.CreateAgent(ctx, AgentCreateInput{Slug: "daily-assistant", Name: "duplicate"}) + _, err = first.CreateAgent(ctx, AgentCreateInput{Code: "daily-assistant", Name: "duplicate"}) requireRPCCode(t, err, errAlreadyExists) updatedAgent, err := first.UpdateAgent(ctx, AgentUpdateInput{ - Slug: "daily-assistant", + Code: "daily-assistant", Name: stringPointer("Senior Daily Assistant"), Description: stringPointer(""), Metadata: map[string]any{"team": "runtime"}, @@ -103,7 +103,7 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { } provider, err := first.CreateProvider(ctx, ProviderCreateInput{ - Slug: "local-openai", + Code: "local-openai", Name: "Local OpenAI", Type: "openai_completions", BaseURL: fixture.BaseURL("openai_completions"), @@ -113,7 +113,7 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { requireNoError(t, err) providerName := "Local OpenAI Compatible" provider, err = first.UpdateProvider(ctx, ProviderUpdateInput{ - Slug: "local-openai", + Code: "local-openai", Name: &providerName, }) requireNoError(t, err) @@ -122,15 +122,15 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { } providers, err := first.ListProviders(ctx) requireNoError(t, err) - if len(providers) != 1 || providers[0].Slug != "local-openai" { + if len(providers) != 1 || providers[0].Code != "local-openai" { t.Fatalf("providers = %+v", providers) } _, err = first.GetProvider(ctx, "local-openai") requireNoError(t, err) provider, err = first.AddModel(ctx, ModelInput{ - ProviderSlug: "local-openai", - ModelSlug: "primary-model", + ProviderCode: "local-openai", + ModelCode: "primary-model", Name: "Primary Model", ContextWindow: 128_000, MaxOutputTokens: 100_000, @@ -145,8 +145,8 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { t.Fatalf("provider models = %+v", provider.Models) } _, err = first.AddModel(ctx, ModelInput{ - ProviderSlug: "local-openai", - ModelSlug: "temporary-model", + ProviderCode: "local-openai", + ModelCode: "temporary-model", Name: "Temporary Model", ContextWindow: 64_000, MaxOutputTokens: 8_192, @@ -154,29 +154,29 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { requireNoError(t, err) primary, err := first.CreateSession(ctx, SessionCreateInput{ - AgentSlug: "daily-assistant", - ProviderSlug: "local-openai", - ModelSlug: "primary-model", + AgentCode: "daily-assistant", + ProviderCode: "local-openai", + ModelCode: "primary-model", ContextWindow: 128_000, }) requireNoError(t, err) secondary, err := first.CreateSession(ctx, SessionCreateInput{ - AgentSlug: "daily-assistant", - ProviderSlug: "local-openai", - ModelSlug: "primary-model", + AgentCode: "daily-assistant", + ProviderCode: "local-openai", + ModelCode: "primary-model", ContextWindow: 64_000, }) requireNoError(t, err) _, err = first.SetSessionTitle(ctx, primary.ID, "Plan the release") requireNoError(t, err) _, err = first.SetSessionModel(ctx, primary.ID, ModelRef{ - ProviderSlug: "local-openai", - ModelSlug: "temporary-model", + ProviderCode: "local-openai", + ModelCode: "temporary-model", }) requireNoError(t, err) _, err = first.SetSessionModel(ctx, primary.ID, ModelRef{ - ProviderSlug: "local-openai", - ModelSlug: "primary-model", + ProviderCode: "local-openai", + ModelCode: "primary-model", }) requireNoError(t, err) _, err = first.SetSessionReasoningEffort(ctx, primary.ID, "high") @@ -188,12 +188,12 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { requireNoError(t, err) summaries, err := first.ListSessions(ctx, SessionListInput{ - AgentSlug: "daily-assistant", + AgentCode: "daily-assistant", Limit: 1, Offset: 1, }) requireNoError(t, err) - if len(summaries) != 1 || summaries[0].AgentSlug != "daily-assistant" { + if len(summaries) != 1 || summaries[0].AgentCode != "daily-assistant" { t.Fatalf("session summaries = %+v", summaries) } @@ -269,9 +269,9 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { } cancelSession, err := second.CreateSession(ctx, SessionCreateInput{ - AgentSlug: "daily-assistant", - ProviderSlug: "local-openai", - ModelSlug: "primary-model", + AgentCode: "daily-assistant", + ProviderCode: "local-openai", + ModelCode: "primary-model", ContextWindow: 128_000, }) requireNoError(t, err) @@ -312,12 +312,12 @@ func TestClientJourneyCoversPublicRPCSurfaceAcrossRestart(t *testing.T) { var chunkedAgent Agent err = second.rpc.CallChunked(ctx, "agent.create", AgentCreateInput{ - Slug: "chunked-agent", + Code: "chunked-agent", Name: "Chunked Agent", Soul: "This payload is split by the client and reassembled by core.", }, 17, &chunkedAgent) requireNoError(t, err) - if chunkedAgent.Slug != "chunked-agent" { + if chunkedAgent.Code != "chunked-agent" { t.Fatalf("chunked agent = %+v", chunkedAgent) } requireNoError(t, second.rpc.AbortChunk(ctx, "aborted-upload", "agent.create")) diff --git a/packages/agenty-core/test/e2e/live_provider_test.go b/packages/agenty-core/test/e2e/live_provider_test.go index cdee7b5..cef8499 100644 --- a/packages/agenty-core/test/e2e/live_provider_test.go +++ b/packages/agenty-core/test/e2e/live_provider_test.go @@ -73,25 +73,25 @@ func runLiveProviderConversation(t *testing.T, tt liveProviderCase) { if apiKey == "" { t.Skipf("%s is not set; skipping live %s E2E conversation", tt.keyEnv, tt.name) } - modelSlug := strings.TrimSpace(os.Getenv(tt.modelEnv)) - if modelSlug == "" { - modelSlug = tt.defaultModel + modelCode := strings.TrimSpace(os.Getenv(tt.modelEnv)) + if modelCode == "" { + modelCode = tt.defaultModel } ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) defer cancel() client := newAgentyClient(startCore(t)) - agentSlug := tt.prefix + "-agent" - providerSlug := tt.prefix + "-provider" + agentCode := tt.prefix + "-agent" + providerCode := tt.prefix + "-provider" _, err := client.CreateAgent(ctx, AgentCreateInput{ - Slug: agentSlug, + Code: agentCode, Name: "Live Provider E2E Agent", Soul: "Follow the user's requested output format exactly.", }) requireNoError(t, err) _, err = client.CreateProvider(ctx, ProviderCreateInput{ - Slug: providerSlug, + Code: providerCode, Name: tt.name, Type: tt.apiType, BaseURL: strings.TrimSpace(os.Getenv(tt.baseURLEnv)), @@ -99,17 +99,17 @@ func runLiveProviderConversation(t *testing.T, tt liveProviderCase) { }) requireNoError(t, err) _, err = client.AddModel(ctx, ModelInput{ - ProviderSlug: providerSlug, - ModelSlug: modelSlug, - Name: modelSlug, + ProviderCode: providerCode, + ModelCode: modelCode, + Name: modelCode, ContextWindow: 128_000, MaxOutputTokens: 64, }) requireNoError(t, err) session, err := client.CreateSession(ctx, SessionCreateInput{ - AgentSlug: agentSlug, - ProviderSlug: providerSlug, - ModelSlug: modelSlug, + AgentCode: agentCode, + ProviderCode: providerCode, + ModelCode: modelCode, ContextWindow: 128_000, }) requireNoError(t, err) diff --git a/packages/agenty-core/test/e2e/protocol_test.go b/packages/agenty-core/test/e2e/protocol_test.go index 8f4bfea..e6b9a5c 100644 --- a/packages/agenty-core/test/e2e/protocol_test.go +++ b/packages/agenty-core/test/e2e/protocol_test.go @@ -19,7 +19,7 @@ func TestStdioJSONRPCSupportsClientTrafficPatterns(t *testing.T) { JSONRPC: "2.0", Method: "agent.create", Params: AgentCreateInput{ - Slug: "notification-agent", + Code: "notification-agent", Name: "通知创建的 Agent 🐈", Soul: "line one\nline two", }, @@ -28,7 +28,7 @@ func TestStdioJSONRPCSupportsClientTrafficPatterns(t *testing.T) { JSONRPC: "2.0", ID: "barrier", Method: "agent.get", - Params: map[string]any{"slug": "notification-agent"}, + Params: map[string]any{"code": "notification-agent"}, }) barrier := readSingleResponse(t, ctx, process) if string(barrier.ID) != `"barrier"` || barrier.Error != nil { diff --git a/packages/agenty-core/test/e2e/provider_fixture_test.go b/packages/agenty-core/test/e2e/provider_fixture_test.go index d6fbe71..fa90750 100644 --- a/packages/agenty-core/test/e2e/provider_fixture_test.go +++ b/packages/agenty-core/test/e2e/provider_fixture_test.go @@ -270,6 +270,8 @@ func providerToolNames(request providerRequest, apiType string) []string { names = append(names, name) } else if apiType == "openai" && tool["type"] == "shell" { names = append(names, "shell") + } else if apiType == "openai" && tool["type"] == "apply_patch" { + names = append(names, "apply_patch") } case "openai_completions": function, _ := tool["function"].(map[string]any) diff --git a/packages/agenty-core/test/e2e/test_helpers_test.go b/packages/agenty-core/test/e2e/test_helpers_test.go index 9f3226b..bbdf9f0 100644 --- a/packages/agenty-core/test/e2e/test_helpers_test.go +++ b/packages/agenty-core/test/e2e/test_helpers_test.go @@ -54,19 +54,19 @@ func createExecutionResources( apiType string, prefix string, ) (Session, error) { - agentSlug := prefix + "-agent" - providerSlug := prefix + "-provider" - modelSlug := prefix + "-model" + agentCode := prefix + "-agent" + providerCode := prefix + "-provider" + modelCode := prefix + "-model" if _, err := client.CreateAgent(ctx, AgentCreateInput{ - Slug: agentSlug, + Code: agentCode, Name: "E2E Agent", Soul: "Answer the user clearly.", }); err != nil { return Session{}, fmt.Errorf("create agent: %w", err) } if _, err := client.CreateProvider(ctx, ProviderCreateInput{ - Slug: providerSlug, + Code: providerCode, Name: "E2E Provider", Type: apiType, BaseURL: fixture.BaseURL(apiType), @@ -75,8 +75,8 @@ func createExecutionResources( return Session{}, fmt.Errorf("create provider: %w", err) } if _, err := client.AddModel(ctx, ModelInput{ - ProviderSlug: providerSlug, - ModelSlug: modelSlug, + ProviderCode: providerCode, + ModelCode: modelCode, Name: "E2E Model", ContextWindow: 128_000, MaxOutputTokens: 8_192, @@ -85,9 +85,9 @@ func createExecutionResources( } session, err := client.CreateSession(ctx, SessionCreateInput{ - AgentSlug: agentSlug, - ProviderSlug: providerSlug, - ModelSlug: modelSlug, + AgentCode: agentCode, + ProviderCode: providerCode, + ModelCode: modelCode, ContextWindow: 128_000, }) if err != nil {