diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e6ceb980..d77384ee 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -150,7 +150,7 @@ Invariant: every business record belongs to exactly one company. - `capabilities` text null - `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway` - `adapter_config` jsonb not null -- `runtime_config` jsonb not null default `{}` +- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config - `default_environment_id` uuid fk `environments.id` null - `context_mode` enum: `thin | fat` default `thin` - `budget_monthly_cents` int not null default 0 diff --git a/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png b/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png new file mode 100644 index 00000000..857c49bc Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-cheap-mobile.png b/docs/pr-screenshots/pap-2837/newissue-cheap-mobile.png new file mode 100644 index 00000000..f2a0a8a1 Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-cheap-mobile.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png b/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png new file mode 100644 index 00000000..348ad164 Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-custom-mobile.png b/docs/pr-screenshots/pap-2837/newissue-custom-mobile.png new file mode 100644 index 00000000..4c54ca9b Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-custom-mobile.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-primary-desktop.png b/docs/pr-screenshots/pap-2837/newissue-primary-desktop.png new file mode 100644 index 00000000..4de3c53d Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-primary-desktop.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-primary-mobile.png b/docs/pr-screenshots/pap-2837/newissue-primary-mobile.png new file mode 100644 index 00000000..a814f94a Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-primary-mobile.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png b/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png new file mode 100644 index 00000000..be272218 Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png differ diff --git a/docs/pr-screenshots/pap-2837/newissue-unsupported-mobile.png b/docs/pr-screenshots/pap-2837/newissue-unsupported-mobile.png new file mode 100644 index 00000000..8bc9aeab Binary files /dev/null and b/docs/pr-screenshots/pap-2837/newissue-unsupported-mobile.png differ diff --git a/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png b/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png new file mode 100644 index 00000000..be334885 Binary files /dev/null and b/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png differ diff --git a/docs/pr-screenshots/pap-2837/runledger-profile-badges-mobile.png b/docs/pr-screenshots/pap-2837/runledger-profile-badges-mobile.png new file mode 100644 index 00000000..0ea760d8 Binary files /dev/null and b/docs/pr-screenshots/pap-2837/runledger-profile-badges-mobile.png differ diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 9dcf068b..c563ab21 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -20,6 +20,8 @@ export type { AdapterSkillContext, AdapterSessionCodec, AdapterModel, + AdapterModelProfileKey, + AdapterModelProfileDefinition, HireApprovedPayload, HireApprovedHookResult, ConfigFieldOption, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 034c1e60..54e456a2 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -144,6 +144,16 @@ export interface AdapterModel { label: string; } +export type AdapterModelProfileKey = "cheap"; + +export interface AdapterModelProfileDefinition { + key: AdapterModelProfileKey; + label: string; + description?: string; + adapterConfig: Record; + source?: "adapter_default" | "discovered"; +} + export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error"; export interface AdapterEnvironmentCheck { @@ -329,6 +339,8 @@ export interface ServerAdapterModule { supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; listModels?: () => Promise; + modelProfiles?: AdapterModelProfileDefinition[]; + listModelProfiles?: () => Promise; /** * Optional explicit refresh hook for model discovery. * Use this when the adapter caches discovered models and needs a bypass path @@ -435,6 +447,14 @@ export interface CreateConfigValues { promptTemplate: string; model: string; thinkingEffort: string; + /** + * Optional cheap model profile config for new agents on adapters that + * support model profiles. Persisted under + * `runtimeConfig.modelProfiles.cheap.adapterConfig`, never on the primary + * `adapterConfig`. + */ + cheapModel?: string; + cheapModelEnabled?: boolean; chrome: boolean; dangerouslySkipPermissions: boolean; search: boolean; diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 88e734eb..9b928ae2 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -1,3 +1,5 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "claude_local"; export const label = "Claude Code (local)"; @@ -10,6 +12,19 @@ export const models = [ { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use Claude Sonnet as the lower-cost Claude Code lane while preserving the agent's primary model.", + adapterConfig: { + model: "claude-sonnet-4-6", + effort: "low", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# claude_local agent configuration Adapter: claude_local diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index e8f0d5a9..ae271820 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -1,5 +1,8 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "codex_local"; export const label = "Codex (local)"; + export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex"; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const; @@ -40,6 +43,19 @@ export const models = [ { id: "codex-mini-latest", label: "Codex Mini" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use the lowest-cost known Codex local model lane without changing the primary model.", + adapterConfig: { + model: "gpt-5.3-codex-spark", + modelReasoningEffort: "low", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# codex_local agent configuration Adapter: codex_local diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index ec26ff66..16fbda54 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -1,5 +1,8 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "cursor"; export const label = "Cursor CLI (local)"; + export const DEFAULT_CURSOR_LOCAL_MODEL = "auto"; const CURSOR_FALLBACK_MODEL_IDS = [ @@ -46,6 +49,18 @@ const CURSOR_FALLBACK_MODEL_IDS = [ export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id })); +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use Cursor's known Codex mini model as the budget lane instead of assuming auto is cheap.", + adapterConfig: { + model: "gpt-5.1-codex-mini", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# cursor agent configuration Adapter: cursor diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 64b7b99f..0e7dcbf4 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -1,5 +1,8 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "gemini_local"; export const label = "Gemini CLI (local)"; + export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; export const models = [ @@ -11,6 +14,18 @@ export const models = [ { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use Gemini Flash Lite as the budget Gemini CLI lane while preserving the primary model.", + adapterConfig: { + model: "gemini-2.5-flash-lite", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# gemini_local agent configuration Adapter: gemini_local diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index c8f37a81..ff326f99 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -1,3 +1,5 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "opencode_local"; export const label = "OpenCode (local)"; @@ -11,6 +13,19 @@ export const models: Array<{ id: string; label: string }> = [ { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use OpenCode's known Codex mini model as the budget lane.", + adapterConfig: { + model: "openai/gpt-5.1-codex-mini", + variant: "low", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# opencode_local agent configuration Adapter: opencode_local diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index a81750c3..6daaa59b 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -1,8 +1,12 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "pi_local"; export const label = "Pi (local)"; export const models: Array<{ id: string; label: string }> = []; +export const modelProfiles: AdapterModelProfileDefinition[] = []; + export const agentConfigurationDoc = `# pi_local agent configuration Adapter: pi_local diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5515de99..16211b38 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -73,6 +73,10 @@ export const AGENT_ROLE_LABELS: Record = { export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch"; + +export const MODEL_PROFILE_KEYS = ["cheap"] as const; +export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number]; + export const AGENT_ICON_NAMES = [ "bot", "cpu", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4935257e..a70ff848 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,6 +13,7 @@ export { AGENT_ROLE_LABELS, AGENT_DEFAULT_MAX_CONCURRENT_RUNS, WORKSPACE_BRANCH_ROUTINE_VARIABLE, + MODEL_PROFILE_KEYS, AGENT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, @@ -117,6 +118,7 @@ export { type AgentStatus, type AgentAdapterType, type AgentRole, + type ModelProfileKey, type AgentIconName, type IssueStatus, type IssuePriority, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index d86d4d03..18aea077 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -1,5 +1,6 @@ import type { AgentAdapterType, + ModelProfileKey, PauseReason, AgentRole, AgentStatus, @@ -13,6 +14,16 @@ export interface AgentPermissions { canCreateAgents: boolean; } +export interface AgentModelProfileConfig { + enabled?: boolean; + label?: string; + adapterConfig: Record; +} + +export interface AgentRuntimeConfig extends Record { + modelProfiles?: Partial>; +} + export type AgentInstructionsBundleMode = "managed" | "external"; export interface AgentInstructionsFileSummary { @@ -72,7 +83,7 @@ export interface Agent { capabilities: string | null; adapterType: AgentAdapterType; adapterConfig: Record; - runtimeConfig: Record; + runtimeConfig: AgentRuntimeConfig; defaultEnvironmentId?: string | null; budgetMonthlyCents: number; spentMonthlyCents: number; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 669575cc..25166759 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -74,7 +74,9 @@ export type { AgentAccessState, AgentChainOfCommandEntry, AgentDetail, + AgentModelProfileConfig, AgentPermissions, + AgentRuntimeConfig, AgentInstructionsBundleMode, AgentInstructionsFileSummary, AgentInstructionsFileDetail, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 853159be..2fa8cb8b 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -6,6 +6,7 @@ import type { IssueExecutionStateStatus, IssueOriginKind, IssuePriority, + ModelProfileKey, IssueThreadInteractionContinuationPolicy, IssueThreadInteractionKind, IssueThreadInteractionStatus, @@ -59,6 +60,7 @@ export interface IssueLabel { } export interface IssueAssigneeAdapterOverrides { + modelProfile?: ModelProfileKey; adapterConfig?: Record; useProjectWorkspace?: boolean; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index a7150774..bfb66751 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -51,6 +51,18 @@ export const createAgentInstructionsBundleSchema = z.object({ }), }); +const agentModelProfileConfigSchema = z.object({ + enabled: z.boolean().optional(), + label: z.string().trim().min(1).optional(), + adapterConfig: adapterConfigSchema, +}).strict(); + +export const agentRuntimeConfigSchema = z.object({ + modelProfiles: z.object({ + cheap: agentModelProfileConfigSchema.optional(), + }).strict().optional(), +}).catchall(z.unknown()); + export const createAgentSchema = z.object({ name: z.string().min(1), role: z.enum(AGENT_ROLES).optional().default("general"), @@ -62,7 +74,7 @@ export const createAgentSchema = z.object({ adapterType: agentAdapterTypeSchema, adapterConfig: adapterConfigSchema.optional().default({}), instructionsBundle: createAgentInstructionsBundleSchema.optional(), - runtimeConfig: z.record(z.unknown()).optional().default({}), + runtimeConfig: agentRuntimeConfigSchema.optional().default({}), defaultEnvironmentId: z.string().uuid().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), permissions: agentPermissionsSchema.optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index dfa28d72..7b916e6b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -107,6 +107,7 @@ export { createAgentSchema, createAgentHireSchema, updateAgentSchema, + agentRuntimeConfigSchema, agentInstructionsBundleModeSchema, updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 662224e4..4117aa42 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -8,6 +8,7 @@ import { updateIssueSchema, upsertIssueDocumentSchema, } from "./issue.js"; +import { createAgentSchema } from "./agent.js"; describe("issue validators", () => { it("passes real line breaks through unchanged", () => { @@ -93,4 +94,87 @@ describe("issue validators", () => { expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH); }); + + it("accepts the cheap model profile in issue assignee adapter overrides", () => { + const parsed = createIssueSchema.parse({ + title: "Run a cheap heartbeat", + assigneeAdapterOverrides: { + modelProfile: "cheap", + }, + }); + + expect(parsed.assigneeAdapterOverrides?.modelProfile).toBe("cheap"); + }); + + it("rejects unknown issue model profile keys", () => { + const parsed = updateIssueSchema.safeParse({ + assigneeAdapterOverrides: { + modelProfile: "fast", + }, + }); + + expect(parsed.success).toBe(false); + }); + + it("validates agent runtime cheap model profile config without rejecting other runtime fields", () => { + const parsed = createAgentSchema.parse({ + name: "Coder", + adapterType: "codex_local", + runtimeConfig: { + heartbeat: { enabled: true }, + modelProfiles: { + cheap: { + enabled: true, + label: "Cheap Codex", + adapterConfig: { + model: "gpt-5.3-codex-spark", + }, + }, + }, + }, + }); + + expect(parsed.runtimeConfig.modelProfiles?.cheap?.adapterConfig).toEqual({ + model: "gpt-5.3-codex-spark", + }); + expect(parsed.runtimeConfig.heartbeat).toEqual({ enabled: true }); + }); + + it("validates cheap model profile env bindings like top-level adapter config", () => { + const parsed = createAgentSchema.safeParse({ + name: "Coder", + adapterType: "codex_local", + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + env: { + API_TOKEN: 123, + }, + }, + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + + it("rejects unknown agent runtime model profile keys", () => { + const parsed = createAgentSchema.safeParse({ + name: "Coder", + adapterType: "codex_local", + runtimeConfig: { + modelProfiles: { + fast: { + adapterConfig: { + model: "gpt-5-mini", + }, + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); }); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 882b3dd8..9533e839 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -10,6 +10,7 @@ import { ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, + MODEL_PROFILE_KEYS, } from "../constants.js"; import { multilineTextSchema } from "./text.js"; @@ -44,6 +45,7 @@ export const issueExecutionWorkspaceSettingsSchema = z export const issueAssigneeAdapterOverridesSchema = z .object({ + modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(), adapterConfig: z.record(z.unknown()).optional(), useProjectWorkspace: z.boolean().optional(), }) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 8567d028..99f91dea 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -28,6 +28,7 @@ import { findActiveServerAdapter, findServerAdapter, listAdapterModels, + listAdapterModelProfiles, registerServerAdapter, requireServerAdapter, unregisterServerAdapter, @@ -79,6 +80,31 @@ describe("server adapter registry", () => { ]); }); + it("exposes adapter model profiles when adapters declare them", async () => { + const adapterWithProfiles: ServerAdapterModule = { + ...externalAdapter, + modelProfiles: [ + { + key: "cheap", + label: "Cheap", + adapterConfig: { model: "external-mini" }, + source: "adapter_default", + }, + ], + }; + + registerServerAdapter(adapterWithProfiles); + + expect(await listAdapterModelProfiles("external_test")).toEqual([ + { + key: "cheap", + label: "Cheap", + adapterConfig: { model: "external-mini" }, + source: "adapter_default", + }, + ]); + }); + it("removes external adapters when unregistered", () => { registerServerAdapter(externalAdapter); @@ -167,6 +193,45 @@ describe("server adapter registry", () => { expect(adapter!.supportsLocalAgentJwt).toBe(true); }); + it("built-in local adapters declare cheap model profile defaults where supported", async () => { + await expect(listAdapterModelProfiles("claude_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "claude-sonnet-4-6" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("codex_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "gpt-5.3-codex-spark" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("gemini_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "gemini-2.5-flash-lite" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("opencode_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "openai/gpt-5.1-codex-mini" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("cursor")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "gpt-5.1-codex-mini" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("pi_local")).resolves.toEqual([]); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 11f039af..85d4f180 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -496,6 +496,165 @@ describe.sequential("agent permission routes", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("blocks agent-authenticated self-updates that set cheap-profile host-executed workspace commands", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + }); + + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }, + }, + }, + })); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(res.body.error).toContain( + "runtimeConfig.modelProfiles.cheap.adapterConfig.workspaceStrategy.provisionCommand", + ); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("allows board updates that set cheap-profile workspace commands", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const runtimeConfig = { + modelProfiles: { + cheap: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + }, + }, + }; + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ runtimeConfig })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + agentId, + expect.objectContaining({ runtimeConfig }), + expect.anything(), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "agent.updated", + })); + }); + + it("normalizes cheap-profile env bindings through the adapter config secret pipeline", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + }); + mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => ({ + ...config, + env: { + API_TOKEN: { + type: "secret_ref", + secretId: "33333333-3333-4333-8333-333333333333", + version: "latest", + }, + }, + })); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + model: "gpt-5.3-codex-spark", + env: { + API_TOKEN: { + type: "secret_ref", + secretId: "33333333-3333-4333-8333-333333333333", + version: "latest", + }, + }, + }, + }, + }, + }, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + model: "gpt-5.3-codex-spark", + env: expect.any(Object), + }), + { strictMode: false }, + ); + expect(mockAgentService.update).toHaveBeenCalledWith( + agentId, + expect.objectContaining({ + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + model: "gpt-5.3-codex-spark", + env: { + API_TOKEN: { + type: "secret_ref", + secretId: "33333333-3333-4333-8333-333333333333", + version: "latest", + }, + }, + }, + }, + }, + }, + }), + expect.anything(), + ); + }); + it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => { const app = await createApp({ type: "agent", diff --git a/server/src/__tests__/heartbeat-model-profile.test.ts b/server/src/__tests__/heartbeat-model-profile.test.ts new file mode 100644 index 00000000..726436ca --- /dev/null +++ b/server/src/__tests__/heartbeat-model-profile.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { AdapterModelProfileDefinition } from "../adapters/index.js"; +import { + mergeModelProfileAdapterConfig, + normalizeModelProfileWakeContext, + resolveModelProfileApplication, +} from "../services/heartbeat.ts"; + +const cheapProfile: AdapterModelProfileDefinition = { + key: "cheap", + label: "Cheap", + adapterConfig: { + model: "adapter-cheap", + modelReasoningEffort: "low", + }, + source: "adapter_default", +}; + +describe("heartbeat model profile application", () => { + it("applies cheap profile patches before explicit issue adapter config overrides", () => { + const modelProfile = resolveModelProfileApplication({ + adapterModelProfiles: [cheapProfile], + agentRuntimeConfig: {}, + issueModelProfile: "cheap", + contextSnapshot: {}, + }); + + const merged = mergeModelProfileAdapterConfig({ + baseConfig: { + model: "primary", + modelReasoningEffort: "high", + approvalPolicy: "strict", + }, + modelProfile, + issueAdapterConfig: { + model: "issue-explicit", + }, + }); + + expect(modelProfile).toMatchObject({ + requested: "cheap", + requestedBy: "issue_override", + applied: "cheap", + configSource: "adapter_default", + fallbackReason: null, + }); + expect(merged).toEqual({ + model: "issue-explicit", + modelReasoningEffort: "low", + approvalPolicy: "strict", + }); + }); + + it("lets agent runtime profile config customize adapter defaults", () => { + const modelProfile = resolveModelProfileApplication({ + adapterModelProfiles: [cheapProfile], + agentRuntimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + model: "agent-cheap", + }, + }, + }, + }, + issueModelProfile: null, + contextSnapshot: { modelProfile: "cheap" }, + }); + + expect(modelProfile).toMatchObject({ + requested: "cheap", + requestedBy: "wake_context", + applied: "cheap", + configSource: "agent_runtime", + adapterConfig: { + model: "agent-cheap", + modelReasoningEffort: "low", + }, + }); + }); + + it("falls back to the primary config when the adapter does not support the requested profile", () => { + const modelProfile = resolveModelProfileApplication({ + adapterModelProfiles: [], + agentRuntimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + model: "agent-cheap", + }, + }, + }, + }, + issueModelProfile: null, + contextSnapshot: { modelProfile: "cheap" }, + }); + + const merged = mergeModelProfileAdapterConfig({ + baseConfig: { + model: "primary", + }, + modelProfile, + issueAdapterConfig: null, + }); + + expect(modelProfile).toMatchObject({ + requested: "cheap", + applied: null, + fallbackReason: "adapter_profile_not_supported", + adapterConfig: null, + }); + expect(merged).toEqual({ model: "primary" }); + }); + + it("normalizes a wake payload model profile into run context", () => { + const contextSnapshot = normalizeModelProfileWakeContext({ + contextSnapshot: {}, + payload: { modelProfile: "cheap" }, + }); + + expect(contextSnapshot).toMatchObject({ modelProfile: "cheap" }); + }); +}); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 9b27b325..0f713c9c 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -6,6 +6,7 @@ export { findServerAdapter, findActiveServerAdapter, detectAdapterModel, + listAdapterModelProfiles, registerServerAdapter, unregisterServerAdapter, requireServerAdapter, @@ -15,6 +16,7 @@ export type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta, + AdapterModelProfileDefinition, AdapterEnvironmentCheckLevel, AdapterEnvironmentCheck, AdapterEnvironmentTestStatus, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 20be46e1..94c38e29 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,4 @@ -import type { ServerAdapterModule } from "./types.js"; +import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, @@ -9,7 +9,11 @@ import { sessionCodec as claudeSessionCodec, getQuotaWindows as claudeGetQuotaWindows, } from "@paperclipai/adapter-claude-local/server"; -import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local"; +import { + agentConfigurationDoc as claudeAgentConfigurationDoc, + models as claudeModels, + modelProfiles as claudeModelProfiles, +} from "@paperclipai/adapter-claude-local"; import { execute as codexExecute, listCodexSkills, @@ -18,7 +22,11 @@ import { sessionCodec as codexSessionCodec, getQuotaWindows as codexGetQuotaWindows, } from "@paperclipai/adapter-codex-local/server"; -import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local"; +import { + agentConfigurationDoc as codexAgentConfigurationDoc, + models as codexModels, + modelProfiles as codexModelProfiles, +} from "@paperclipai/adapter-codex-local"; import { execute as cursorExecute, listCursorSkills, @@ -26,7 +34,11 @@ import { testEnvironment as cursorTestEnvironment, sessionCodec as cursorSessionCodec, } from "@paperclipai/adapter-cursor-local/server"; -import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; +import { + agentConfigurationDoc as cursorAgentConfigurationDoc, + models as cursorModels, + modelProfiles as cursorModelProfiles, +} from "@paperclipai/adapter-cursor-local"; import { execute as geminiExecute, listGeminiSkills, @@ -34,7 +46,11 @@ import { testEnvironment as geminiTestEnvironment, sessionCodec as geminiSessionCodec, } from "@paperclipai/adapter-gemini-local/server"; -import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local"; +import { + agentConfigurationDoc as geminiAgentConfigurationDoc, + models as geminiModels, + modelProfiles as geminiModelProfiles, +} from "@paperclipai/adapter-gemini-local"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -46,6 +62,7 @@ import { import { agentConfigurationDoc as openCodeAgentConfigurationDoc, models as openCodeModels, + modelProfiles as openCodeModelProfiles, } from "@paperclipai/adapter-opencode-local"; import { execute as openclawGatewayExecute, @@ -67,6 +84,7 @@ import { } from "@paperclipai/adapter-pi-local/server"; import { agentConfigurationDoc as piAgentConfigurationDoc, + modelProfiles as piModelProfiles, } from "@paperclipai/adapter-pi-local"; import { execute as hermesExecute, @@ -126,6 +144,7 @@ const claudeLocalAdapter: ServerAdapterModule = { sessionCodec: claudeSessionCodec, sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, + modelProfiles: claudeModelProfiles, listModels: listClaudeModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, @@ -144,6 +163,7 @@ const codexLocalAdapter: ServerAdapterModule = { sessionCodec: codexSessionCodec, sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, + modelProfiles: codexModelProfiles, listModels: listCodexModels, refreshModels: refreshCodexModels, supportsLocalAgentJwt: true, @@ -163,6 +183,7 @@ const cursorLocalAdapter: ServerAdapterModule = { sessionCodec: cursorSessionCodec, sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, + modelProfiles: cursorModelProfiles, listModels: listCursorModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, @@ -180,6 +201,7 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionCodec: geminiSessionCodec, sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, + modelProfiles: geminiModelProfiles, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", @@ -206,6 +228,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { syncSkills: syncOpenCodeSkills, sessionCodec: openCodeSessionCodec, models: openCodeModels, + modelProfiles: openCodeModelProfiles, sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, @@ -224,6 +247,7 @@ const piLocalAdapter: ServerAdapterModule = { sessionCodec: piSessionCodec, sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], + modelProfiles: piModelProfiles, listModels: listPiModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, @@ -474,6 +498,16 @@ export async function refreshAdapterModels(type: string): Promise<{ id: string; return adapter.models ?? []; } +export async function listAdapterModelProfiles(type: string): Promise { + const adapter = findActiveServerAdapter(type); + if (!adapter) return []; + if (adapter.listModelProfiles) { + const discovered = await adapter.listModelProfiles(); + if (discovered.length > 0) return discovered; + } + return adapter.modelProfiles ?? []; +} + export function listServerAdapters(): ServerAdapterModule[] { return Array.from(adaptersByType.values()); } diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index b8f32568..0b728b9c 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -22,6 +22,8 @@ export type { AdapterSkillContext, AdapterSessionCodec, AdapterModel, + AdapterModelProfileKey, + AdapterModelProfileDefinition, NativeContextManagement, ResolvedSessionCompactionPolicy, SessionCompactionPolicy, diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index efe52b48..8d1d2cec 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -66,6 +66,7 @@ interface AdapterCapabilities { supportsSkills: boolean; supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; + supportsModelProfiles: boolean; } interface AdapterInfo { @@ -119,6 +120,7 @@ function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabili supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles), }; } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b3c7701b..3fb95eda 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -63,6 +63,7 @@ import { findActiveServerAdapter, findServerAdapter, listAdapterModels, + listAdapterModelProfiles, refreshAdapterModels, requireServerAdapter, } from "../adapters/index.js"; @@ -710,6 +711,99 @@ export function agentRoutes( return normalizedRuntimeConfig; } + function listRuntimeModelProfileAdapterConfigs(runtimeConfig: unknown): Array<{ + profileKey: string; + profile: Record; + adapterConfig: Record; + path: string; + }> { + const runtimeRecord = asRecord(runtimeConfig); + const modelProfiles = asRecord(runtimeRecord?.modelProfiles); + if (!modelProfiles) return []; + + const entries: Array<{ + profileKey: string; + profile: Record; + adapterConfig: Record; + path: string; + }> = []; + for (const [profileKey, rawProfile] of Object.entries(modelProfiles)) { + const profile = asRecord(rawProfile); + const adapterConfig = asRecord(profile?.adapterConfig); + if (!profile || !adapterConfig) continue; + entries.push({ + profileKey, + profile, + adapterConfig, + path: `runtimeConfig.modelProfiles.${profileKey}.adapterConfig`, + }); + } + return entries; + } + + function assertNoAgentRuntimeConfigAdapterConfigMutation(req: Request, runtimeConfig: unknown) { + for (const entry of listRuntimeModelProfileAdapterConfigs(runtimeConfig)) { + assertNoAgentAdapterConfigMutation(req, entry.adapterConfig, entry.path); + } + } + + async function normalizeMediatedAdapterConfigForPersistence(input: { + companyId: string; + adapterType: string | null | undefined; + adapterConfig: Record; + constraintAdapterConfig?: Record; + }): Promise> { + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + input.companyId, + input.adapterConfig, + { strictMode: strictSecretsMode }, + ); + await assertAdapterConfigConstraints( + input.companyId, + input.adapterType, + input.constraintAdapterConfig + ? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig } + : normalizedAdapterConfig, + ); + return normalizedAdapterConfig; + } + + async function normalizeRuntimeConfigAdapterConfigsForPersistence( + companyId: string, + adapterType: string, + runtimeConfig: Record, + baseAdapterConfig: Record, + ): Promise> { + const entries = listRuntimeModelProfileAdapterConfigs(runtimeConfig); + if (entries.length === 0) return runtimeConfig; + const adapterModelProfiles = await listAdapterModelProfiles(adapterType); + + const normalizedRuntimeConfig = { ...runtimeConfig }; + const modelProfiles = asRecord(runtimeConfig.modelProfiles) ?? {}; + const normalizedModelProfiles = { ...modelProfiles }; + normalizedRuntimeConfig.modelProfiles = normalizedModelProfiles; + + for (const entry of entries) { + const adapterProfile = adapterModelProfiles.find((profile) => profile.key === entry.profileKey); + const adapterDefaultConfig = asRecord(adapterProfile?.adapterConfig) ?? {}; + const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ + companyId, + adapterType, + adapterConfig: entry.adapterConfig, + constraintAdapterConfig: { + ...baseAdapterConfig, + ...adapterDefaultConfig, + }, + }); + normalizedModelProfiles[entry.profileKey] = { + ...entry.profile, + adapterConfig: normalizedAdapterConfig, + }; + } + + return normalizedRuntimeConfig; + } + function generateEd25519PrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("ed25519"); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); @@ -866,15 +960,34 @@ export function agentRoutes( function assertNoAgentInstructionsConfigMutation( req: Request, adapterConfig: Record | null | undefined, + path = "adapterConfig", ) { if (req.actor.type !== "agent" || !adapterConfig) return; - const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined); + const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS + .filter((key) => adapterConfig[key] !== undefined) + .map((key) => `${path}.${key}`); if (changedSensitiveKeys.length === 0) return; throw forbidden( `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, ); } + function adapterConfigTouchesInstructionsConfig(adapterConfig: Record) { + return KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => adapterConfig[key] !== undefined); + } + + function assertNoAgentAdapterConfigMutation( + req: Request, + adapterConfig: Record, + path = "adapterConfig", + ) { + assertNoAgentInstructionsConfigMutation(req, adapterConfig, path); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(adapterConfig, path), + ); + } + function summarizeAgentUpdateDetails(patch: Record) { const changedTopLevelKeys = Object.keys(patch).sort(); const details: Record = { changedTopLevelKeys }; @@ -1064,6 +1177,14 @@ export function agentRoutes( res.json(models); }); + router.get("/companies/:companyId/adapters/:type/model-profiles", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const type = assertKnownAdapterType(req.params.type as string); + const profiles = await listAdapterModelProfiles(type); + res.json(profiles); + }); + router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -1624,21 +1745,16 @@ export function agentRoutes( ...hireInput } = req.body; hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); + const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record; assertNoNewAgentLegacyPromptTemplate( hireInput.adapterType, - (hireInput.adapterConfig ?? {}) as Record, - ); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig), - ); - assertNoAgentInstructionsConfigMutation( - req, - (hireInput.adapterConfig ?? {}) as Record, + rawHireAdapterConfig, ); + assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig); + assertNoAgentRuntimeConfigAdapterConfigMutation(req, hireInput.runtimeConfig); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, - ((hireInput.adapterConfig ?? {}) as Record), + rawHireAdapterConfig, ); const desiredSkillAssignment = await resolveDesiredSkillAssignment( companyId, @@ -1646,20 +1762,21 @@ export function agentRoutes( requestedAdapterConfig, Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); - const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, - desiredSkillAssignment.adapterConfig, - { strictMode: strictSecretsMode }, - ); - await assertAdapterConfigConstraints( + adapterType: hireInput.adapterType, + adapterConfig: desiredSkillAssignment.adapterConfig, + }); + const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( companyId, hireInput.adapterType, + normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig), normalizedAdapterConfig, ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, - runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig), + runtimeConfig: normalizedRuntimeConfig, }; const company = await db @@ -1814,21 +1931,16 @@ export function agentRoutes( ...createInput } = req.body; createInput.adapterType = assertKnownAdapterType(createInput.adapterType); + const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertNoNewAgentLegacyPromptTemplate( createInput.adapterType, - (createInput.adapterConfig ?? {}) as Record, - ); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig), - ); - assertNoAgentInstructionsConfigMutation( - req, - (createInput.adapterConfig ?? {}) as Record, + rawCreateAdapterConfig, ); + assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig); + assertNoAgentRuntimeConfigAdapterConfigMutation(req, createInput.runtimeConfig); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( createInput.adapterType, - ((createInput.adapterConfig ?? {}) as Record), + rawCreateAdapterConfig, ); const desiredSkillAssignment = await resolveDesiredSkillAssignment( companyId, @@ -1836,14 +1948,15 @@ export function agentRoutes( requestedAdapterConfig, Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); - const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, - desiredSkillAssignment.adapterConfig, - { strictMode: strictSecretsMode }, - ); - await assertAdapterConfigConstraints( + adapterType: createInput.adapterType, + adapterConfig: desiredSkillAssignment.adapterConfig, + }); + const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( companyId, createInput.adapterType, + normalizeNewAgentRuntimeConfig(createInput.runtimeConfig), normalizedAdapterConfig, ); await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId); @@ -1855,7 +1968,7 @@ export function agentRoutes( const createdAgent = await svc.create(companyId, { ...createInput, adapterConfig: normalizedAdapterConfig, - runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig), + runtimeConfig: normalizedRuntimeConfig, status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, @@ -2230,14 +2343,8 @@ export function agentRoutes( res.status(422).json({ error: "adapterConfig must be an object" }); return; } - assertNoAgentInstructionsConfigMutation(req, adapterConfig); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(adapterConfig), - ); - const changingInstructionsConfig = Object.keys(adapterConfig).some((key) => - KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), - ); + assertNoAgentAdapterConfigMutation(req, adapterConfig); + const changingInstructionsConfig = adapterConfigTouchesInstructionsConfig(adapterConfig); if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } @@ -2247,6 +2354,16 @@ export function agentRoutes( const requestedAdapterType = hasOwn(patchData, "adapterType") ? assertKnownAdapterType(patchData.adapterType as string | null | undefined) : existing.adapterType; + let requestedRuntimeConfig: Record | null = null; + if (hasOwn(patchData, "runtimeConfig")) { + const runtimeConfig = asRecord(patchData.runtimeConfig); + if (!runtimeConfig) { + res.status(422).json({ error: "runtimeConfig must be an object" }); + return; + } + assertNoAgentRuntimeConfigAdapterConfigMutation(req, runtimeConfig); + requestedRuntimeConfig = runtimeConfig; + } const touchesAdapterConfiguration = hasOwn(patchData, "adapterType") || hasOwn(patchData, "adapterConfig"); @@ -2292,19 +2409,20 @@ export function agentRoutes( requestedAdapterType, rawEffectiveAdapterConfig, ); - const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, - effectiveAdapterConfig, - { strictMode: strictSecretsMode }, - ); + const normalizedEffectiveAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ + companyId: existing.companyId, + adapterType: requestedAdapterType, + adapterConfig: effectiveAdapterConfig, + }); patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig); } - if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { - const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; - await assertAdapterConfigConstraints( + if (requestedRuntimeConfig) { + const baseAdapterConfig = asRecord(patchData.adapterConfig) ?? asRecord(existing.adapterConfig) ?? {}; + patchData.runtimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( existing.companyId, requestedAdapterType, - effectiveAdapterConfig, + requestedRuntimeConfig, + baseAdapterConfig, ); } if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) { diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts index e7999d11..6c56e9d9 100644 --- a/server/src/routes/workspace-command-authz.ts +++ b/server/src/routes/workspace-command-authz.ts @@ -47,11 +47,14 @@ export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: s ); } -export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] { +export function collectAgentAdapterWorkspaceCommandPaths( + adapterConfig: unknown, + prefix = "adapterConfig", +): string[] { if (!isRecord(adapterConfig)) return []; return collectWorkspaceStrategyCommandPaths( adapterConfig.workspaceStrategy, - "adapterConfig.workspaceStrategy", + `${prefix}.workspaceStrategy`, ); } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 25535ed6..a76b15a8 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8,11 +8,13 @@ import type { Db } from "@paperclipai/db"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + MODEL_PROFILE_KEYS, isEnvironmentDriverSupportedForAdapter, type BillingType, type EnvironmentLeaseStatus, type ExecutionWorkspace, type ExecutionWorkspaceConfig, + type ModelProfileKey, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -38,8 +40,14 @@ import { conflict, HttpError, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { publishLiveEvent } from "./live-events.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; -import { getServerAdapter, runningProcesses } from "../adapters/index.js"; -import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js"; +import { getServerAdapter, listAdapterModelProfiles, runningProcesses } from "../adapters/index.js"; +import type { + AdapterExecutionResult, + AdapterInvocationMeta, + AdapterModelProfileDefinition, + AdapterSessionCodec, + UsageSummary, +} from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithByteCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; @@ -879,10 +887,23 @@ type SessionCompactionDecision = { }; interface ParsedIssueAssigneeAdapterOverrides { + modelProfile: ModelProfileKey | null; adapterConfig: Record | null; useProjectWorkspace: boolean | null; } +type ModelProfileRequestSource = "issue_override" | "wake_context"; +type AppliedModelProfileConfigSource = "agent_runtime" | "adapter_default"; + +export interface ModelProfileApplication { + requested: ModelProfileKey | null; + requestedBy: ModelProfileRequestSource | null; + applied: ModelProfileKey | null; + configSource: AppliedModelProfileConfigSource | null; + fallbackReason: string | null; + adapterConfig: Record | null; +} + export type ResolvedWorkspaceForRun = { cwd: string; source: "project_primary" | "task_session" | "agent_home"; @@ -917,6 +938,147 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +function readModelProfileKey(value: unknown): ModelProfileKey | null { + return MODEL_PROFILE_KEYS.includes(value as ModelProfileKey) + ? (value as ModelProfileKey) + : null; +} + +function readContextModelProfile( + contextSnapshot: Record | null | undefined, +): ModelProfileKey | null { + return readModelProfileKey(contextSnapshot?.modelProfile); +} + +export function normalizeModelProfileWakeContext(input: { + contextSnapshot: Record; + payload: Record | null | undefined; +}): Record { + const modelProfileFromPayload = readModelProfileKey(input.payload?.modelProfile); + if (!readContextModelProfile(input.contextSnapshot) && modelProfileFromPayload) { + input.contextSnapshot.modelProfile = modelProfileFromPayload; + } + return input.contextSnapshot; +} + +function readAgentRuntimeModelProfile( + runtimeConfig: unknown, + key: ModelProfileKey, +): { enabled: boolean; adapterConfig: Record; configured: boolean } { + const modelProfiles = parseObject(parseObject(runtimeConfig).modelProfiles); + const profile = parseObject(modelProfiles[key]); + if (Object.keys(profile).length === 0) { + return { enabled: true, adapterConfig: {}, configured: false }; + } + + return { + enabled: profile.enabled !== false, + adapterConfig: parseObject(profile.adapterConfig), + configured: true, + }; +} + +export function resolveModelProfileApplication(input: { + adapterModelProfiles: AdapterModelProfileDefinition[]; + agentRuntimeConfig: unknown; + issueModelProfile: ModelProfileKey | null | undefined; + contextSnapshot: Record | null | undefined; + profileResolutionFallbackReason?: string | null; +}): ModelProfileApplication { + const issueModelProfile = input.issueModelProfile ?? null; + const contextModelProfile = readContextModelProfile(input.contextSnapshot); + const requested = issueModelProfile ?? contextModelProfile; + const requestedBy: ModelProfileRequestSource | null = issueModelProfile + ? "issue_override" + : contextModelProfile + ? "wake_context" + : null; + + if (!requested) { + return { + requested: null, + requestedBy: null, + applied: null, + configSource: null, + fallbackReason: null, + adapterConfig: null, + }; + } + + const adapterProfile = input.adapterModelProfiles.find((profile) => profile.key === requested) ?? null; + if (!adapterProfile) { + return { + requested, + requestedBy, + applied: null, + configSource: null, + fallbackReason: input.profileResolutionFallbackReason ?? "adapter_profile_not_supported", + adapterConfig: null, + }; + } + + const runtimeProfile = readAgentRuntimeModelProfile(input.agentRuntimeConfig, requested); + if (!runtimeProfile.enabled) { + return { + requested, + requestedBy, + applied: null, + configSource: null, + fallbackReason: "agent_runtime_profile_disabled", + adapterConfig: null, + }; + } + + return { + requested, + requestedBy, + applied: requested, + configSource: runtimeProfile.configured ? "agent_runtime" : "adapter_default", + fallbackReason: null, + adapterConfig: { + ...parseObject(adapterProfile.adapterConfig), + ...runtimeProfile.adapterConfig, + }, + }; +} + +export function mergeModelProfileAdapterConfig(input: { + baseConfig: Record; + modelProfile: ModelProfileApplication; + issueAdapterConfig: Record | null | undefined; +}): Record { + return { + ...input.baseConfig, + ...(input.modelProfile.adapterConfig ?? {}), + ...(input.issueAdapterConfig ?? {}), + }; +} + +function modelProfileRunMetadata( + modelProfile: ModelProfileApplication, +): Record | null { + if (!modelProfile.requested) return null; + return { + requested: modelProfile.requested, + requestedBy: modelProfile.requestedBy, + applied: modelProfile.applied, + configSource: modelProfile.configSource, + fallbackReason: modelProfile.fallbackReason, + }; +} + +function mergeModelProfileRunMetadata( + resultJson: Record | null, + modelProfile: ModelProfileApplication, +): Record | null { + const metadata = modelProfileRunMetadata(modelProfile); + if (!metadata) return resultJson; + return { + ...(resultJson ?? {}), + modelProfile: metadata, + }; +} + export function summarizeHeartbeatRunContextSnapshot( contextSnapshot: Record | null | undefined, ): Record | null { @@ -930,6 +1092,7 @@ export function summarizeHeartbeatRunContextSnapshot( "wakeReason", "wakeSource", "wakeTriggerDetail", + "modelProfile", ] as const; for (const key of allowedKeys) { @@ -1259,6 +1422,9 @@ function parseIssueAssigneeAdapterOverrides( raw: unknown, ): ParsedIssueAssigneeAdapterOverrides | null { const parsed = parseObject(raw); + const modelProfile = MODEL_PROFILE_KEYS.includes(parsed.modelProfile as ModelProfileKey) + ? parsed.modelProfile as ModelProfileKey + : null; const parsedAdapterConfig = parseObject(parsed.adapterConfig); const adapterConfig = Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null; @@ -1266,8 +1432,9 @@ function parseIssueAssigneeAdapterOverrides( typeof parsed.useProjectWorkspace === "boolean" ? parsed.useProjectWorkspace : null; - if (!adapterConfig && useProjectWorkspace === null) return null; + if (!modelProfile && !adapterConfig && useProjectWorkspace === null) return null; return { + modelProfile, adapterConfig, useProjectWorkspace, }; @@ -1551,6 +1718,7 @@ function enrichWakeContextSnapshot(input: { if (!readNonEmptyString(contextSnapshot["wakeTriggerDetail"]) && triggerDetail) { contextSnapshot.wakeTriggerDetail = triggerDetail; } + normalizeModelProfileWakeContext({ contextSnapshot, payload }); return { contextSnapshot, @@ -4964,9 +5132,42 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) workspaceConfig: existingExecutionWorkspace?.config ?? null, mode: effectiveExecutionWorkspaceMode, }); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } - : persistedWorkspaceManagedConfig; + let adapterModelProfiles: AdapterModelProfileDefinition[] = []; + let profileResolutionFallbackReason: string | null = null; + try { + adapterModelProfiles = await listAdapterModelProfiles(agent.adapterType); + } catch (error) { + profileResolutionFallbackReason = "adapter_profile_resolution_failed"; + logger.warn( + { + err: error, + companyId: agent.companyId, + agentId: agent.id, + adapterType: agent.adapterType, + runId: run.id, + }, + "Failed to resolve adapter model profiles; falling back to primary adapter config", + ); + } + const modelProfileApplication = resolveModelProfileApplication({ + adapterModelProfiles, + agentRuntimeConfig: agent.runtimeConfig, + issueModelProfile: issueAssigneeOverrides?.modelProfile ?? null, + contextSnapshot: context, + profileResolutionFallbackReason, + }); + const modelProfileMetadata = modelProfileRunMetadata(modelProfileApplication); + if (modelProfileMetadata) { + context.paperclipModelProfile = modelProfileMetadata; + if (modelProfileApplication.requested) context.modelProfile = modelProfileApplication.requested; + } else { + delete context.paperclipModelProfile; + } + const mergedConfig = mergeModelProfileAdapterConfig({ + baseConfig: persistedWorkspaceManagedConfig, + modelProfile: modelProfileApplication, + issueAdapterConfig: issueAssigneeOverrides?.adapterConfig ?? null, + }); const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId); const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({ @@ -5527,12 +5728,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (key in meta.env) meta.env[key] = "***REDACTED***"; } } + const modelProfileMetadata = modelProfileRunMetadata(modelProfileApplication); await appendRunEvent(currentRun, seq++, { eventType: "adapter.invoke", stream: "system", level: "info", message: "adapter invocation", - payload: meta as unknown as Record, + payload: { + ...(meta as unknown as Record), + ...(modelProfileMetadata ? { modelProfile: modelProfileMetadata } : {}), + }, }); }; @@ -5715,11 +5920,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const persistedResultJson = mergeHeartbeatRunResultJson( mergeRunStopMetadataForAgent(agent, outcome, { - resultJson: mergeAdapterRecoveryMetadata({ - resultJson: adapterResult.resultJson ?? null, - errorFamily: adapterResult.errorFamily ?? null, - retryNotBefore: adapterResult.retryNotBefore ?? null, - }), + resultJson: mergeModelProfileRunMetadata( + mergeAdapterRecoveryMetadata({ + resultJson: adapterResult.resultJson ?? null, + errorFamily: adapterResult.errorFamily ?? null, + retryNotBefore: adapterResult.retryNotBefore ?? null, + }), + modelProfileApplication, + ), errorCode: runErrorCode, errorMessage: runErrorMessage, }), diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 7c16e1c9..631c01f3 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -8,6 +8,7 @@ const ALL_FALSE: AdapterCapabilities = { supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: false, }; /** @@ -15,13 +16,13 @@ const ALL_FALSE: AdapterCapabilities = { * return correct values on first render before the /api/adapters call resolves. */ const KNOWN_DEFAULTS: Record = { - claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, + codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, + cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, + hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, openclaw_gateway: ALL_FALSE, }; diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 18b154ef..3df1340c 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -9,6 +9,7 @@ export interface AdapterCapabilities { supportsSkills: boolean; supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; + supportsModelProfiles: boolean; } export interface AdapterInfo { diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 713adba2..d3e79fc0 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -13,6 +13,10 @@ import type { Approval, AgentConfigRevision, } from "@paperclipai/shared"; +import type { + AdapterModelProfileDefinition, + AdapterModelProfileKey, +} from "@paperclipai/adapter-utils"; import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; import { ApiError, api } from "./client"; @@ -28,6 +32,9 @@ export interface AdapterModel { label: string; } +export type { AdapterModelProfileKey }; +export type AdapterModelProfile = AdapterModelProfileDefinition; + export interface DetectedAdapterModel { model: string; provider: string; @@ -172,6 +179,10 @@ export const agentsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, ), + adapterModelProfiles: (companyId: string, type: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/model-profiles`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 7af982d4..f1af84a6 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -41,6 +41,7 @@ import { help, adapterLabels, } from "./agent-config-primitives"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; @@ -117,7 +118,8 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean { o.adapterType !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || - Object.keys(o.runtime).length > 0 + Object.keys(o.runtime).length > 0 || + o.modelProfiles?.cheap !== undefined ); } @@ -244,15 +246,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); + type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; + /** Read effective value: overlay if dirty, else original */ - function eff(group: keyof Omit, field: string, original: T): T { + function eff(group: RecordOverlayGroup, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ - function mark(group: keyof Omit, field: string, value: unknown) { + function mark(group: RecordOverlayGroup, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, @@ -374,8 +378,30 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); // Popover states const [modelOpen, setModelOpen] = useState(false); + const [cheapModelOpen, setCheapModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); + // Cheap model profile state — only relevant when the adapter advertises + // `supportsModelProfiles`. Defaults are sourced from the adapter's + // /model-profiles endpoint so the UI does not encode adapter-specific + // cheap defaults. + const supportsModelProfiles = adapterCaps.supportsModelProfiles; + const { data: adapterCheapProfileDefinitions } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModelProfiles(selectedCompanyId, adapterType) + : ["agents", "none", "adapter-model-profiles", adapterType], + queryFn: () => agentsApi.adapterModelProfiles(selectedCompanyId!, adapterType), + enabled: Boolean(selectedCompanyId) && supportsModelProfiles, + }); + const adapterCheapDefault = useMemo(() => { + return (adapterCheapProfileDefinitions ?? []).find((profile) => profile.key === "cheap") ?? null; + }, [adapterCheapProfileDefinitions]); + const adapterCheapDefaultModel = useMemo(() => { + const adapterConfig = adapterCheapDefault?.adapterConfig ?? {}; + const value = (adapterConfig as Record).model; + return typeof value === "string" ? value : ""; + }, [adapterCheapDefault]); + // Create mode helpers const val = isCreate ? props.values : null; const set = isCreate @@ -516,6 +542,69 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; + // Cheap profile read/write helpers. Edit-mode values come from + // runtimeConfig.modelProfiles.cheap with overlay overrides on top; create-mode + // values come straight from CreateConfigValues (cheapModel + cheapModelEnabled). + const cheapProfileFromAgent = useMemo(() => { + const profiles = (runtimeConfig.modelProfiles ?? {}) as Record; + const cheap = (profiles.cheap ?? {}) as Record; + const cheapAdapterConfig = (cheap.adapterConfig ?? {}) as Record; + return { + enabled: cheap.enabled !== false, + model: typeof cheapAdapterConfig.model === "string" ? cheapAdapterConfig.model : "", + }; + }, [runtimeConfig]); + const cheapOverlay = !isCreate ? overlay.modelProfiles?.cheap : undefined; + const currentCheapEnabled = isCreate + ? val!.cheapModelEnabled ?? false + : cheapOverlay?.enabled ?? cheapProfileFromAgent.enabled; + const currentCheapModel = isCreate + ? val!.cheapModel ?? "" + : (() => { + const overlayModel = (cheapOverlay?.adapterConfig as Record | undefined)?.model; + if (typeof overlayModel === "string") return overlayModel; + return cheapProfileFromAgent.model; + })(); + + function setCheapEnabled(next: boolean) { + if (isCreate) { + set!({ cheapModelEnabled: next }); + return; + } + setOverlay((prev) => ({ + ...prev, + modelProfiles: { + cheap: { + ...(prev.modelProfiles?.cheap ?? {}), + enabled: next, + }, + }, + })); + } + + function setCheapModel(next: string) { + if (isCreate) { + set!({ cheapModel: next }); + return; + } + setOverlay((prev) => { + const existing = prev.modelProfiles?.cheap ?? {}; + const nextAdapterConfig = { + ...((existing.adapterConfig ?? {}) as Record), + model: next || undefined, + }; + return { + ...prev, + modelProfiles: { + cheap: { + ...existing, + adapterConfig: nextAdapterConfig, + }, + }, + }; + }); + } + const effectiveRuntimeConfig = useMemo(() => { if (isCreate) { return { @@ -720,6 +809,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setOverlay((prev) => ({ ...prev, adapterType: t, + modelProfiles: { cheap: { cleared: true } }, adapterConfig: { model: t === "codex_local" @@ -832,6 +922,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { /> + {supportsModelProfiles && ( +
Primary model
+ )} )} + {supportsModelProfiles && ( + + )} + {showThinkingEffort && ( <> {selected ? selected.label - : value || (allowDefault ? "Default" : required ? "Select model (required)" : "Select model")} + : value + || (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")} @@ -1500,6 +1610,72 @@ function ModelDropdown({ ); } +function CheapModelSection({ + enabled, + model, + models, + adapterType, + adapterDefaultModel, + onEnabledChange, + onModelChange, + open, + onOpenChange, +}: { + enabled: boolean; + model: string; + models: AdapterModel[]; + adapterType: string; + adapterDefaultModel: string; + onEnabledChange: (next: boolean) => void; + onModelChange: (next: string) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const placeholderHint = adapterDefaultModel + ? `Adapter default · ${adapterDefaultModel}` + : "No adapter default — choose a cheaper model"; + return ( +
+
+
+
Cheap model
+

+ Used when a run requests the cheap profile (e.g. routine summaries). The primary model stays unchanged. +

+
+ +
+ {enabled ? ( + + ) : null} + {enabled && !model && adapterDefaultModel ? ( +

+ No explicit cheap model selected — runtime falls back to {adapterDefaultModel}. +

+ ) : null} + {enabled && !model && !adapterDefaultModel ? ( +

+ No cheap model selected and the adapter has no default. Cheap-lane runs will continue on the primary model with a fallback note. +

+ ) : null} +
+ ); +} + function ThinkingEffortDropdown({ value, options, diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 90d5bbb3..e3baabc4 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -431,6 +431,41 @@ describe("IssueRunLedger", () => { }); }); + it("renders requested/applied model profile and surfaces fallback reasons", () => { + renderLedger({ + runs: [ + createRun({ + runId: "run-cheap-applied", + resultJson: { + modelProfile: { + requested: "cheap", + applied: "cheap", + configSource: "agent_runtime", + fallbackReason: null, + }, + }, + }), + createRun({ + runId: "run-cheap-fallback", + createdAt: "2026-04-18T19:50:00.000Z", + resultJson: { + modelProfile: { + requested: "cheap", + applied: null, + configSource: null, + fallbackReason: "agent_runtime_profile_disabled", + }, + }, + }), + ], + }); + + expect(container.textContent).toContain("Profile: cheap"); + expect(container.textContent).toContain("Profile: cheap (unavailable)"); + expect(container.textContent).toContain("Cheap profile fell back to primary"); + expect(container.textContent).toContain("agent_runtime_profile_disabled"); + }); + it("hides watchdog decision actions for known non-owner viewers", () => { const onWatchdogDecision = vi.fn(); renderLedger({ diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 7b0a5b5e..e45bd4ae 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -159,6 +159,45 @@ function readString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +interface ModelProfileSummary { + requested: string; + applied: string | null; + configSource: string | null; + fallbackReason: string | null; +} + +function modelProfileForRun(run: RunForIssue): ModelProfileSummary | null { + const result = asRecord(run.resultJson); + const profile = asRecord(result?.modelProfile); + if (!profile) return null; + const requested = readString(profile.requested); + if (!requested) return null; + return { + requested, + applied: readString(profile.applied), + configSource: readString(profile.configSource), + fallbackReason: readString(profile.fallbackReason), + }; +} + +function modelProfileBadgeTone(summary: ModelProfileSummary) { + if (summary.applied === summary.requested) { + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + } + if (summary.fallbackReason) { + return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + } + return "border-border bg-background text-muted-foreground"; +} + +function modelProfileTitle(summary: ModelProfileSummary) { + const lines = [`Requested: ${summary.requested}`]; + if (summary.applied) lines.push(`Applied: ${summary.applied}`); + if (summary.configSource) lines.push(`Source: ${summary.configSource}`); + if (summary.fallbackReason) lines.push(`Fallback: ${summary.fallbackReason}`); + return lines.join("\n"); +} + function readNumber(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -713,6 +752,26 @@ export function IssueRunLedgerContent({ {RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label} ) : null} + {(() => { + const profile = modelProfileForRun(run); + if (!profile) return null; + const label = profile.applied === profile.requested + ? `Profile: ${profile.requested}` + : profile.applied + ? `Profile: ${profile.requested} → ${profile.applied}` + : `Profile: ${profile.requested} (unavailable)`; + return ( + + {label} + + ); + })()} {relativeTime(item.timestamp)} @@ -749,6 +808,20 @@ export function IssueRunLedgerContent({ ) : null} + {(() => { + const profile = modelProfileForRun(run); + if (!profile?.fallbackReason || profile.applied === profile.requested) return null; + return ( +

+ {profile.requested === "cheap" + ? "Cheap profile fell back to primary" + : `${profile.requested} profile unavailable`} + {": "} + {profile.fallbackReason} +

+ ); + })()} + {run.livenessReason ? (

{run.livenessReason} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index e05d1379..41bbb229 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { pickTextColorForSolidBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -77,6 +78,7 @@ interface IssueDraft { assigneeId?: string; projectId: string; projectWorkspaceId?: string; + assigneeModelLane?: IssueModelLane; assigneeModelOverride: string; assigneeThinkingEffort: string; assigneeChrome: boolean; @@ -93,7 +95,12 @@ type StagedIssueFile = { title?: string | null; }; -const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]); +import { + buildAssigneeAdapterOverrides, + ISSUE_OVERRIDE_ADAPTER_TYPES, + type IssueModelLane, +} from "../lib/issue-assignee-overrides"; + const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"; const ISSUE_THINKING_EFFORT_OPTIONS = { @@ -122,41 +129,6 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { ], } as const; -function buildAssigneeAdapterOverrides(input: { - adapterType: string | null | undefined; - modelOverride: string; - thinkingEffortOverride: string; - chrome: boolean; -}): Record | null { - const adapterType = input.adapterType ?? null; - if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) { - return null; - } - - const adapterConfig: Record = {}; - if (input.modelOverride) adapterConfig.model = input.modelOverride; - if (input.thinkingEffortOverride) { - if (adapterType === "codex_local") { - adapterConfig.modelReasoningEffort = input.thinkingEffortOverride; - } else if (adapterType === "opencode_local") { - adapterConfig.variant = input.thinkingEffortOverride; - } else if (adapterType === "claude_local") { - adapterConfig.effort = input.thinkingEffortOverride; - } else if (adapterType === "opencode_local") { - adapterConfig.variant = input.thinkingEffortOverride; - } - } - if (adapterType === "claude_local" && input.chrome) { - adapterConfig.chrome = true; - } - - const overrides: Record = {}; - if (Object.keys(adapterConfig).length > 0) { - overrides.adapterConfig = adapterConfig; - } - return Object.keys(overrides).length > 0 ? overrides : null; -} - function loadDraft(): IssueDraft | null { try { const raw = localStorage.getItem(DRAFT_KEY); @@ -406,6 +378,7 @@ export function NewIssueDialog() { const [projectId, setProjectId] = useState(""); const [projectWorkspaceId, setProjectWorkspaceId] = useState(""); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); + const [assigneeModelLane, setAssigneeModelLane] = useState("primary"); const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState(""); const [assigneeChrome, setAssigneeChrome] = useState(false); @@ -496,6 +469,25 @@ export function NewIssueDialog() { const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); + const getAdapterCapabilities = useAdapterCapabilities(); + const assigneeAdapterCapabilities = assigneeAdapterType + ? getAdapterCapabilities(assigneeAdapterType) + : null; + const assigneeSupportsCheapLane = Boolean( + supportsAssigneeOverrides && assigneeAdapterCapabilities?.supportsModelProfiles, + ); + + const { data: assigneeCheapProfiles } = useQuery({ + queryKey: effectiveCompanyId && assigneeAdapterType + ? queryKeys.agents.adapterModelProfiles(effectiveCompanyId, assigneeAdapterType) + : ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"], + queryFn: () => agentsApi.adapterModelProfiles(effectiveCompanyId!, assigneeAdapterType!), + enabled: Boolean(effectiveCompanyId) && newIssueOpen && assigneeSupportsCheapLane, + }); + const assigneeCheapProfile = useMemo( + () => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null, + [assigneeCheapProfiles], + ); const mentionOptions = useMemo(() => { return buildMarkdownMentionOptions({ agents, @@ -612,6 +604,7 @@ export function NewIssueDialog() { approverValue, projectId, projectWorkspaceId, + assigneeModelLane, assigneeModelOverride, assigneeThinkingEffort, assigneeChrome, @@ -663,6 +656,7 @@ export function NewIssueDialog() { approverValue, projectId, projectWorkspaceId, + assigneeModelLane, assigneeModelOverride, assigneeThinkingEffort, assigneeChrome, @@ -700,6 +694,7 @@ export function NewIssueDialog() { setProjectId(defaultProjectId); setProjectWorkspaceId(defaultProjectWorkspaceId); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -744,6 +739,7 @@ export function NewIssueDialog() { setShowApproverRow(!!(draft.approverValue)); setProjectId(restoredProjectId); setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)); + setAssigneeModelLane(draft.assigneeModelLane ?? "primary"); setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); setAssigneeChrome(draft.assigneeChrome ?? false); @@ -780,11 +776,15 @@ export function NewIssueDialog() { useEffect(() => { if (!supportsAssigneeOverrides) { setAssigneeOptionsOpen(false); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); return; } + if (!assigneeSupportsCheapLane && assigneeModelLane === "cheap") { + setAssigneeModelLane("primary"); + } const validThinkingValues = assigneeAdapterType === "codex_local" @@ -795,7 +795,13 @@ export function NewIssueDialog() { if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) { setAssigneeThinkingEffort(""); } - }, [supportsAssigneeOverrides, assigneeAdapterType, assigneeThinkingEffort]); + }, [ + supportsAssigneeOverrides, + assigneeAdapterType, + assigneeThinkingEffort, + assigneeSupportsCheapLane, + assigneeModelLane, + ]); // Cleanup timer on unmount useEffect(() => { @@ -816,6 +822,7 @@ export function NewIssueDialog() { setProjectId(""); setProjectWorkspaceId(""); setAssigneeOptionsOpen(false); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -841,6 +848,7 @@ export function NewIssueDialog() { setShowApproverRow(false); setProjectId(""); setProjectWorkspaceId(""); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -858,8 +866,14 @@ export function NewIssueDialog() { const currentTitle = titleRef.current.trim(); const currentDescription = descriptionRef.current.trim(); if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return; + const effectiveLane = assigneeSupportsCheapLane + ? assigneeModelLane + : assigneeModelLane === "cheap" + ? "primary" + : assigneeModelLane; const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({ adapterType: assigneeAdapterType, + lane: effectiveLane, modelOverride: assigneeModelOverride, thinkingEffortOverride: assigneeThinkingEffort, chrome: assigneeChrome, @@ -1557,36 +1571,84 @@ export function NewIssueDialog() { {assigneeOptionsOpen && (

-
Model
- -
-
-
Thinking effort
-
- {thinkingEffortOptions.map((option) => ( +
Model lane
+
+ {(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => ( ))}
+ {assigneeModelLane === "cheap" && ( +

+ Sends modelProfile: "cheap"{" "} + {assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record).model === "string" + ? <>· adapter default {String((assigneeCheapProfile.adapterConfig as Record).model)} + : assigneeCheapProfile + ? <>· uses the agent's configured cheap profile + : <>· falls back to the primary model if no cheap profile is configured} +

+ )} + {assigneeModelLane === "primary" && ( +

Runs on the agent's primary model.

+ )} + {assigneeModelLane === "custom" && ( +

Override the model and effort for this issue only.

+ )}
- {assigneeAdapterType === "claude_local" && ( + {assigneeModelLane === "custom" && ( +
+
Model
+ +
+ )} + {assigneeModelLane === "custom" && ( +
+
Thinking effort
+
+ {thinkingEffortOptions.map((option) => ( + + ))} +
+
+ )} + {assigneeAdapterType === "claude_local" && assigneeModelLane === "custom" && (
Enable Chrome (--chrome)
{ }); }); + it("writes the cheap profile under runtimeConfig.modelProfiles, never on primary adapterConfig", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: { model: "claude-haiku-4-5" }, + }, + }, + }), + ); + + expect(patch).toEqual({ + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 300, + }, + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: { model: "claude-haiku-4-5" }, + }, + }, + }, + }); + // The primary adapterConfig is untouched. + expect(patch.adapterConfig).toBeUndefined(); + }); + + it("merges cheap profile changes onto existing runtimeConfig.modelProfiles state", () => { + const agent = makeAgent(); + agent.runtimeConfig = { + heartbeat: { enabled: true, intervalSec: 300 }, + modelProfiles: { + cheap: { + enabled: false, + adapterConfig: { model: "old-cheap" }, + }, + }, + }; + + const patch = buildAgentUpdatePatch( + agent, + makeOverlay({ + modelProfiles: { + cheap: { + enabled: true, + }, + }, + }), + ); + + expect((patch.runtimeConfig as Record).modelProfiles).toEqual({ + cheap: { + enabled: true, + adapterConfig: { model: "old-cheap" }, + }, + }); + }); + + it("clears the cheap profile when the overlay marks it cleared", () => { + const agent = makeAgent(); + agent.runtimeConfig = { + heartbeat: { enabled: true, intervalSec: 300 }, + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: { model: "claude-haiku-4-5" }, + }, + }, + }; + + const patch = buildAgentUpdatePatch( + agent, + makeOverlay({ + modelProfiles: { cheap: { cleared: true } }, + }), + ); + + expect(patch.runtimeConfig).toEqual({ + heartbeat: { enabled: true, intervalSec: 300 }, + }); + }); + it("preserves adapter-agnostic keys when changing adapter types", () => { const patch = buildAgentUpdatePatch( makeAgent(), diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts index 323f9bf5..e57e0c05 100644 --- a/ui/src/lib/agent-config-patch.ts +++ b/ui/src/lib/agent-config-patch.ts @@ -1,11 +1,22 @@ import type { Agent } from "@paperclipai/shared"; +export interface AgentModelProfileOverlay { + enabled?: boolean; + adapterConfig?: Record; + /** + * Mark the cheap profile for clearing. When true, the patch removes + * `runtimeConfig.modelProfiles.cheap` instead of merging into it. + */ + cleared?: boolean; +} + export interface AgentConfigOverlay { identity: Record; adapterType?: string; adapterConfig: Record; heartbeat: Record; runtime: Record; + modelProfiles?: { cheap?: AgentModelProfileOverlay }; } const ADAPTER_AGNOSTIC_KEYS = [ @@ -56,10 +67,47 @@ export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) patch.replaceAdapterConfig = true; } - if (Object.keys(overlay.heartbeat).length > 0) { + const cheapOverlay = overlay.modelProfiles?.cheap; + const hasModelProfileChange = cheapOverlay !== undefined; + + if (Object.keys(overlay.heartbeat).length > 0 || hasModelProfileChange) { const existingRc = (agent.runtimeConfig ?? {}) as Record; - const existingHb = (existingRc.heartbeat ?? {}) as Record; - patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; + const nextRuntimeConfig: Record = (patch.runtimeConfig as Record | undefined) + ?? { ...existingRc }; + + if (Object.keys(overlay.heartbeat).length > 0) { + const existingHb = (existingRc.heartbeat ?? {}) as Record; + nextRuntimeConfig.heartbeat = { ...existingHb, ...overlay.heartbeat }; + } + + if (hasModelProfileChange) { + const existingProfiles = ((existingRc.modelProfiles ?? {}) as Record); + const existingCheap = ((existingProfiles.cheap ?? {}) as Record); + const nextProfiles = { ...existingProfiles }; + + if (cheapOverlay?.cleared) { + delete nextProfiles.cheap; + } else if (cheapOverlay) { + const mergedAdapterConfig = { + ...((existingCheap.adapterConfig ?? {}) as Record), + ...(cheapOverlay.adapterConfig ?? {}), + }; + const enabled = cheapOverlay.enabled ?? (existingCheap.enabled !== false); + nextProfiles.cheap = { + ...existingCheap, + enabled, + adapterConfig: mergedAdapterConfig, + }; + } + + if (Object.keys(nextProfiles).length === 0) { + delete nextRuntimeConfig.modelProfiles; + } else { + nextRuntimeConfig.modelProfiles = nextProfiles; + } + } + + patch.runtimeConfig = nextRuntimeConfig; } if (Object.keys(overlay.runtime).length > 0) { diff --git a/ui/src/lib/issue-assignee-overrides.test.ts b/ui/src/lib/issue-assignee-overrides.test.ts new file mode 100644 index 00000000..bcc3f0ba --- /dev/null +++ b/ui/src/lib/issue-assignee-overrides.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { buildAssigneeAdapterOverrides } from "./issue-assignee-overrides"; + +describe("buildAssigneeAdapterOverrides", () => { + it("returns null for adapters that do not accept issue overrides", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "process", + lane: "custom", + modelOverride: "anything", + thinkingEffortOverride: "high", + chrome: true, + }), + ).toBeNull(); + }); + + it("primary lane sends nothing", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "claude_local", + lane: "primary", + modelOverride: "", + thinkingEffortOverride: "", + chrome: false, + }), + ).toBeNull(); + }); + + it("cheap lane sends modelProfile=cheap and no adapterConfig", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "codex_local", + lane: "cheap", + modelOverride: "ignored", + thinkingEffortOverride: "high", + chrome: false, + }), + ).toEqual({ modelProfile: "cheap" }); + }); + + it("custom lane preserves explicit model + thinking effort + chrome overrides", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "claude_local", + lane: "custom", + modelOverride: "claude-haiku-4-5", + thinkingEffortOverride: "high", + chrome: true, + }), + ).toEqual({ + adapterConfig: { + model: "claude-haiku-4-5", + effort: "high", + chrome: true, + }, + }); + }); + + it("custom lane returns null when no fields are set", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "codex_local", + lane: "custom", + modelOverride: "", + thinkingEffortOverride: "", + chrome: false, + }), + ).toBeNull(); + }); + + it("custom lane uses adapter-specific keys for thinking effort", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "codex_local", + lane: "custom", + modelOverride: "", + thinkingEffortOverride: "minimal", + chrome: false, + }), + ).toEqual({ + adapterConfig: { modelReasoningEffort: "minimal" }, + }); + expect( + buildAssigneeAdapterOverrides({ + adapterType: "opencode_local", + lane: "custom", + modelOverride: "", + thinkingEffortOverride: "max", + chrome: false, + }), + ).toEqual({ + adapterConfig: { variant: "max" }, + }); + }); +}); diff --git a/ui/src/lib/issue-assignee-overrides.ts b/ui/src/lib/issue-assignee-overrides.ts new file mode 100644 index 00000000..bdbcfc03 --- /dev/null +++ b/ui/src/lib/issue-assignee-overrides.ts @@ -0,0 +1,60 @@ +export const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "opencode_local", +]); + +export type IssueModelLane = "primary" | "cheap" | "custom"; + +export interface BuildAssigneeAdapterOverridesInput { + adapterType: string | null | undefined; + lane: IssueModelLane; + modelOverride: string; + thinkingEffortOverride: string; + chrome: boolean; +} + +/** + * Build the `assigneeAdapterOverrides` payload sent to the issue create API. + * + * Lane semantics: + * - "primary" → no overrides, runs on the agent's primary model. + * - "cheap" → `modelProfile: "cheap"` only; the runtime resolves the actual + * adapter config from the agent's runtimeConfig + adapter default. + * - "custom" → preserves the legacy explicit override path + * (`adapterConfig.model`, thinking effort, chrome). + */ +export function buildAssigneeAdapterOverrides( + input: BuildAssigneeAdapterOverridesInput, +): Record | null { + const adapterType = input.adapterType ?? null; + if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) { + return null; + } + + if (input.lane === "primary") { + return null; + } + + if (input.lane === "cheap") { + return { modelProfile: "cheap" }; + } + + const adapterConfig: Record = {}; + if (input.modelOverride) adapterConfig.model = input.modelOverride; + if (input.thinkingEffortOverride) { + if (adapterType === "codex_local") { + adapterConfig.modelReasoningEffort = input.thinkingEffortOverride; + } else if (adapterType === "opencode_local") { + adapterConfig.variant = input.thinkingEffortOverride; + } else if (adapterType === "claude_local") { + adapterConfig.effort = input.thinkingEffortOverride; + } + } + if (adapterType === "claude_local" && input.chrome) { + adapterConfig.chrome = true; + } + + if (Object.keys(adapterConfig).length === 0) return null; + return { adapterConfig }; +} diff --git a/ui/src/lib/new-agent-hire-payload.ts b/ui/src/lib/new-agent-hire-payload.ts index ce699de0..2fbe6d3c 100644 --- a/ui/src/lib/new-agent-hire-payload.ts +++ b/ui/src/lib/new-agent-hire-payload.ts @@ -32,6 +32,8 @@ export function buildNewAgentHirePayload(input: { runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: configValues.heartbeatEnabled, intervalSec: configValues.intervalSec, + cheapModel: configValues.cheapModel, + cheapModelEnabled: configValues.cheapModelEnabled, }), budgetMonthlyCents: 0, }; diff --git a/ui/src/lib/new-agent-runtime-config.test.ts b/ui/src/lib/new-agent-runtime-config.test.ts index 7c38437e..f33cdc85 100644 --- a/ui/src/lib/new-agent-runtime-config.test.ts +++ b/ui/src/lib/new-agent-runtime-config.test.ts @@ -31,4 +31,35 @@ describe("buildNewAgentRuntimeConfig", () => { }, }); }); + + it("stores cheap model under modelProfiles.cheap, not primary adapterConfig", () => { + const config = buildNewAgentRuntimeConfig({ + heartbeatEnabled: true, + intervalSec: 600, + cheapModel: "claude-sonnet-4-6", + cheapModelEnabled: true, + }); + + expect(config.modelProfiles).toEqual({ + cheap: { + enabled: true, + adapterConfig: { model: "claude-sonnet-4-6" }, + }, + }); + // primary heartbeat config still present + expect(config.heartbeat).toMatchObject({ enabled: true, intervalSec: 600 }); + }); + + it("omits modelProfiles when no cheap model is configured", () => { + const config = buildNewAgentRuntimeConfig({ heartbeatEnabled: false }); + expect(config.modelProfiles).toBeUndefined(); + }); + + it("omits modelProfiles when cheap model is set but explicitly disabled", () => { + const config = buildNewAgentRuntimeConfig({ + cheapModel: "claude-sonnet-4-6", + cheapModelEnabled: false, + }); + expect(config.modelProfiles).toBeUndefined(); + }); }); diff --git a/ui/src/lib/new-agent-runtime-config.ts b/ui/src/lib/new-agent-runtime-config.ts index 2f2e452a..0e106b98 100644 --- a/ui/src/lib/new-agent-runtime-config.ts +++ b/ui/src/lib/new-agent-runtime-config.ts @@ -4,8 +4,10 @@ import { defaultCreateValues } from "../components/agent-config-defaults"; export function buildNewAgentRuntimeConfig(input?: { heartbeatEnabled?: boolean; intervalSec?: number; -}) { - return { + cheapModel?: string; + cheapModelEnabled?: boolean; +}): Record { + const config: Record = { heartbeat: { enabled: input?.heartbeatEnabled ?? defaultCreateValues.heartbeatEnabled, intervalSec: input?.intervalSec ?? defaultCreateValues.intervalSec, @@ -14,4 +16,17 @@ export function buildNewAgentRuntimeConfig(input?: { maxConcurrentRuns: AGENT_DEFAULT_MAX_CONCURRENT_RUNS, }, }; + + const cheapModel = input?.cheapModel?.trim() ?? ""; + const cheapEnabled = input?.cheapModelEnabled ?? false; + if (cheapModel && cheapEnabled) { + config.modelProfiles = { + cheap: { + enabled: true, + adapterConfig: { model: cheapModel }, + }, + }; + } + + return config; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 25d73a1d..7290c28a 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -25,6 +25,8 @@ export const queryKeys = { configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-models", adapterType] as const, + adapterModelProfiles: (companyId: string, adapterType: string) => + ["agents", companyId, "adapter-model-profiles", adapterType] as const, detectModel: (companyId: string, adapterType: string) => ["agents", companyId, "detect-model", adapterType] as const, }, diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index acda6877..94532a19 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -615,6 +615,7 @@ export function AdapterManager() { supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: false, }, }} canRemove={false} diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 46a01e9e..3d0397d2 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -87,6 +87,63 @@ function installStorybookApiFixtures() { }); } + if (url.pathname === "/api/adapters") { + return Response.json([ + { + type: "claude_local", + label: "Claude Local", + source: "builtin", + modelsCount: 2, + loaded: true, + disabled: false, + capabilities: { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: true, + }, + }, + { + type: "codex_local", + label: "Codex Local", + source: "builtin", + modelsCount: 3, + loaded: true, + disabled: false, + capabilities: { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: true, + }, + }, + ]); + } + + const adapterModelsMatch = url.pathname.match( + /^\/api\/companies\/[^/]+\/adapters\/([^/]+)\/(models|model-profiles)$/, + ); + if (adapterModelsMatch) { + const [, , resource] = adapterModelsMatch; + if (resource === "models") { + return Response.json([ + { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, + ]); + } + return Response.json([ + { + key: "cheap", + label: "Cheap", + adapterConfig: { model: "claude-sonnet-4-6" }, + source: "adapter_default", + }, + ]); + } + if (url.pathname === "/api/plugins/ui-contributions") { return Response.json([]); } diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agent-management.stories.tsx index a77ed84e..9e9ead37 100644 --- a/ui/storybook/stories/agent-management.stories.tsx +++ b/ui/storybook/stories/agent-management.stories.tsx @@ -291,6 +291,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, + supportsModelProfiles: true, }, }, { @@ -305,6 +306,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, + supportsModelProfiles: true, }, }, { @@ -319,6 +321,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: false, }, }, ]; diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/dialogs-modals.stories.tsx index 4a2cc720..1d9e968d 100644 --- a/ui/storybook/stories/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/dialogs-modals.stories.tsx @@ -19,6 +19,7 @@ import { PathInstructionsModal } from "@/components/PathInstructionsModal"; import { useCompany } from "@/context/CompanyContext"; import { useDialog } from "@/context/DialogContext"; import { queryKeys } from "@/lib/queryKeys"; +import type { Agent } from "@paperclipai/shared"; import { storybookAgents, storybookAuthSession, @@ -328,7 +329,7 @@ function DialogBackdropFrame({ } function hydrateDialogQueries(queryClient: ReturnType) { - queryClient.setQueryData(queryKeys.companies.all, storybookCompanies); + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), storybookAgents); queryClient.setQueryData(queryKeys.projects.list(COMPANY_ID), storybookProjects); @@ -382,6 +383,7 @@ function hydrateDialogQueries(queryClient: ReturnType) { supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: true, }, }, { @@ -396,6 +398,7 @@ function hydrateDialogQueries(queryClient: ReturnType) { supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: true, }, }, ]); @@ -403,8 +406,41 @@ function hydrateDialogQueries(queryClient: ReturnType) { { id: "gpt-5.4", label: "GPT-5.4" }, { id: "gpt-5.4-mini", label: "GPT-5.4 Mini" }, ]); + queryClient.setQueryData(queryKeys.agents.adapterModelProfiles(COMPANY_ID, "codex_local"), [ + { + key: "cheap", + label: "Cheap", + adapterConfig: { model: "gpt-5.4-mini" }, + source: "adapter_default", + }, + ]); } +const HERMES_AGENT: Agent = { + id: "agent-hermes", + companyId: COMPANY_ID, + name: "HermesRouter", + urlKey: "hermesrouter", + role: "engineer", + title: "Lightweight Routing", + icon: "code", + status: "idle", + reportsTo: "agent-cto", + capabilities: "Hermes-backed assistant on an adapter without the cheap-profile contract.", + adapterType: "opencode_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 60_000, + spentMonthlyCents: 9_000, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: new Date("2026-04-29T08:30:00.000Z"), + metadata: null, + createdAt: new Date("2026-04-12T08:00:00.000Z"), + updatedAt: new Date("2026-04-29T08:30:00.000Z"), +}; + function StorybookDialogFixtures({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [ready] = useState(() => { @@ -655,6 +691,128 @@ function ImageGalleryModalStory() { ); } +type CheapLaneVariant = "primary" | "cheap" | "custom" | "unsupported"; + +function clickModelLaneButton(label: "Primary" | "Cheap" | "Custom") { + const radiogroup = document.querySelector("[aria-label='Model lane']"); + if (!radiogroup) return false; + const buttons = Array.from(radiogroup.querySelectorAll("button[role='radio']")); + const button = buttons.find((candidate) => candidate.textContent?.trim() === label); + if (!button) return false; + button.click(); + return true; +} + +function findAssigneeOptionsButton() { + const buttons = Array.from(document.querySelectorAll("button")); + return ( + buttons.find((candidate) => /(Codex|Claude|OpenCode|Agent) options$/.test(candidate.textContent?.trim() ?? "")) ?? null + ); +} + +function useCheapLaneAdapterOverrides(variant: CheapLaneVariant) { + const queryClient = useQueryClient(); + useLayoutEffect(() => { + if (variant !== "unsupported") return; + queryClient.setQueryData( + queryKeys.agents.list(COMPANY_ID), + [...storybookAgents, HERMES_AGENT], + ); + queryClient.setQueryData(queryKeys.adapters.all, [ + { + type: "codex_local", + label: "Codex local", + source: "builtin", + modelsCount: 5, + loaded: true, + disabled: false, + capabilities: { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: true, + }, + }, + { + type: "opencode_local", + label: "OpenCode local", + source: "builtin", + modelsCount: 2, + loaded: true, + disabled: false, + capabilities: { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: true, + supportsModelProfiles: false, + }, + }, + ]); + queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "opencode_local"), [ + { id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" }, + { id: "openai/gpt-5.4-mini", label: "GPT-5.4 Mini" }, + ]); + }, [queryClient, variant]); +} + +function CheapLaneIssueDialogOpener({ variant }: { variant: CheapLaneVariant }) { + const { openNewIssue } = useDialog(); + useCheapLaneAdapterOverrides(variant); + + const assigneeAgentId = variant === "unsupported" ? "agent-hermes" : "agent-codex"; + const title = + variant === "unsupported" + ? "Route research summary to HermesRouter" + : "Generate weekly Storybook coverage report"; + const description = + variant === "unsupported" + ? "HermesRouter runs on an adapter that does not advertise a cheap profile, so the Cheap lane should disappear instead of being greyed." + : "Lower-cost runs should still pick up the agent's cheap profile so the model badge can show the requested lane."; + + useOpenWhenCompanyReady(() => { + openNewIssue({ + title, + description, + status: "todo", + priority: "medium", + projectId: "project-board-ui", + projectWorkspaceId: "workspace-board-ui", + assigneeAgentId, + }); + }); + + useEffect(() => { + let cancelled = false; + const timers: number[] = []; + + timers.push( + window.setTimeout(() => { + if (cancelled) return; + const optionsButton = findAssigneeOptionsButton(); + optionsButton?.click(); + }, 300), + ); + + if (variant === "cheap" || variant === "custom") { + timers.push( + window.setTimeout(() => { + if (cancelled) return; + clickModelLaneButton(variant === "cheap" ? "Cheap" : "Custom"); + }, 600), + ); + } + + return () => { + cancelled = true; + for (const timer of timers) window.clearTimeout(timer); + }; + }, [variant]); + + return ; +} + function PathInstructionsModalStory() { return ( ( + + + + ), +}; + +export const NewIssueCheapLaneCheap: Story = { + name: "New Issue - Cheap lane (Cheap)", + render: () => ( + + + + ), +}; + +export const NewIssueCheapLaneCustom: Story = { + name: "New Issue - Cheap lane (Custom)", + render: () => ( + + + + ), +}; + +export const NewIssueCheapLaneUnsupported: Story = { + name: "New Issue - Cheap lane (Unsupported adapter)", + render: () => ( + + + + ), +}; + export const NewAgentRecommendation: Story = { name: "New Agent - Recommendation", render: () => ( diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/issue-management.stories.tsx index f46fd926..c6544db3 100644 --- a/ui/storybook/stories/issue-management.stories.tsx +++ b/ui/storybook/stories/issue-management.stories.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Issue } from "@paperclipai/shared"; +import type { RunForIssue } from "@/api/activity"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowDownAZ, @@ -83,7 +84,7 @@ function Section({ } function hydrateStorybookQueries(queryClient: ReturnType) { - queryClient.setQueryData(queryKeys.companies.all, storybookCompanies); + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); queryClient.setQueryData(queryKeys.agents.list(companyId), storybookAgents); queryClient.setQueryData(queryKeys.projects.list(companyId), storybookProjects); @@ -334,6 +335,116 @@ function OpenFiltersPopover() { ); } +const modelProfileLedgerRuns: RunForIssue[] = [ + { + runId: "run-cheap-applied", + status: "succeeded", + agentId: "agent-codex", + adapterType: "codex_local", + startedAt: "2026-04-29T09:30:00.000Z", + finishedAt: "2026-04-29T09:32:14.000Z", + createdAt: "2026-04-29T09:29:55.000Z", + invocationSource: "manual", + usageJson: { costCents: 17, inputTokens: 6400, outputTokens: 480 }, + resultJson: { + stopReason: "completed", + modelProfile: { + requested: "cheap", + applied: "cheap", + configSource: "agent_runtime_config", + }, + }, + livenessState: "advanced", + livenessReason: "Cheap-lane summary completed inside the planned scope.", + continuationAttempt: 0, + lastUsefulActionAt: "2026-04-29T09:32:10.000Z", + nextAction: "Hand the routine output back to the operator inbox.", + }, + { + runId: "run-cheap-fallback", + status: "succeeded", + agentId: "agent-codex", + adapterType: "codex_local", + startedAt: "2026-04-29T08:10:00.000Z", + finishedAt: "2026-04-29T08:14:42.000Z", + createdAt: "2026-04-29T08:09:50.000Z", + invocationSource: "manual", + usageJson: { costCents: 91, inputTokens: 21800, outputTokens: 3200 }, + resultJson: { + stopReason: "completed", + modelProfile: { + requested: "cheap", + applied: "primary", + configSource: "adapter_default", + fallbackReason: "Cheap profile not configured for this agent", + }, + }, + livenessState: "advanced", + livenessReason: "Routine fell back to the primary model after the cheap lookup missed.", + continuationAttempt: 0, + lastUsefulActionAt: "2026-04-29T08:14:36.000Z", + nextAction: "Configure agent-codex with a cheap profile to avoid the fallback.", + }, + { + runId: "run-baseline", + status: "succeeded", + agentId: "agent-codex", + adapterType: "codex_local", + startedAt: "2026-04-28T18:05:00.000Z", + finishedAt: "2026-04-28T18:14:11.000Z", + createdAt: "2026-04-28T18:04:50.000Z", + invocationSource: "scheduler", + usageJson: { costCents: 142, inputTokens: 38400, outputTokens: 7200 }, + resultJson: { stopReason: "completed" }, + livenessState: "advanced", + livenessReason: "Standard primary-lane run with no profile metadata recorded.", + continuationAttempt: 0, + lastUsefulActionAt: "2026-04-28T18:13:58.000Z", + nextAction: "Continue with the next planned subtask.", + }, +]; + +function ModelProfileBadgeLedger() { + return ( +
+ + + + + + Model profile metadata + + + Profile badges read resultJson.modelProfile on each run. Applied matching the request renders + emerald; an applied fallback renders amber and surfaces the inline reason. + + + +
+
Profile: cheap
+

requested + applied both equal cheap → emerald badge.

+
+
+
Profile: cheap → primary
+

cheap requested but primary applied → amber badge plus inline fallback reason.

+
+
+
No profile badge
+

Run with no modelProfile metadata renders without a badge for visual contrast.

+
+
+
+
+ ); +} + function RunLedgerWithCostColumns() { return (
@@ -549,6 +660,10 @@ function IssueManagementStories() { +
+ +
+
@@ -599,3 +714,40 @@ export default meta; type Story = StoryObj; export const FullSurfaceMatrix: Story = {}; + +function ModelProfileLedgerStandalone() { + return ( + +
+
+
+
+
+
IssueRunLedger
+

Model profile badges

+

+ Run ledger isolated to the cheap-lane visual states: an emerald applied=cheap badge, an amber + cheap-fell-back-to-primary badge with the inline fallback reason, and a baseline run without a + modelProfile so the visual diff stays obvious. +

+
+
+ cheap applied + cheap → primary + no profile +
+
+
+
+ +
+
+
+
+ ); +} + +export const RunLedgerModelProfileBadges: Story = { + name: "Run ledger - Model profile badges", + render: () => , +}; diff --git a/ui/storybook/stories/projects-goals-workspaces.stories.tsx b/ui/storybook/stories/projects-goals-workspaces.stories.tsx index 76d83d9d..c10f5958 100644 --- a/ui/storybook/stories/projects-goals-workspaces.stories.tsx +++ b/ui/storybook/stories/projects-goals-workspaces.stories.tsx @@ -66,7 +66,7 @@ function Section({ function hydrateStorybookQueries(queryClient: ReturnType) { queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); - queryClient.setQueryData(queryKeys.companies.all, storybookCompanies); + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), storybookAgents); queryClient.setQueryData(queryKeys.projects.list(COMPANY_ID), storybookProjects); queryClient.setQueryData(queryKeys.projects.detail(boardProject.id), boardProject); diff --git a/ui/storybook/stories/sub-issues-workflow.stories.tsx b/ui/storybook/stories/sub-issues-workflow.stories.tsx index 87ac834d..4f4132d4 100644 --- a/ui/storybook/stories/sub-issues-workflow.stories.tsx +++ b/ui/storybook/stories/sub-issues-workflow.stories.tsx @@ -172,7 +172,7 @@ const viewStateKey = "storybook:sub-issues-workflow:list"; const scopedKey = `${viewStateKey}:${companyId}`; function hydrateQueries(client: ReturnType) { - client.setQueryData(queryKeys.companies.all, storybookCompanies); + client.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); client.setQueryData(queryKeys.auth.session, storybookAuthSession); client.setQueryData(queryKeys.agents.list(companyId), storybookAgents); client.setQueryData(queryKeys.projects.list(companyId), storybookProjects);