Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
09063ab
feat(ai-powerups): resolve models per capability instead of taking th…
adrians5j Sep 8, 2026
504d586
fix(api-graphql): honour `multiple: true` on resolver dependencies
adrians5j Sep 8, 2026
14fedc6
fix(ai-powerups): accept the nulls the settings form sends, and stop …
adrians5j Sep 8, 2026
3cb9845
fix(ai-powerups): render the accordion descriptions and name the inhe…
adrians5j Sep 8, 2026
e8bfa40
fix(ai-powerups): stop persisting a prompt nobody chose to own
adrians5j Sep 8, 2026
abd936a
fix(ai-powerups): compare prompt content instead of keying off the sw…
adrians5j Sep 8, 2026
8ede8ff
feat(ai-powerups)!: drop the prompt override, keep only appending
adrians5j Sep 9, 2026
978bdcd
docs(ai-powerups): correct two capability comments left stale by the …
adrians5j Sep 9, 2026
b628ef0
Merge remote-tracking branch 'origin/next' into adrian/ai-settings-ca…
adrians5j Sep 16, 2026
5f49a00
refactor(ai-powerups): log skipped AI work through the Logger, not co…
adrians5j Sep 12, 2026
f79ea06
refactor(ai-powerups): stop passing the capability twice when composi…
adrians5j Sep 12, 2026
36e063b
fix(ai-powerups): sort the Capabilities list alphabetically
adrians5j Sep 14, 2026
52aed3f
refactor(ai-powerups): list capabilities through a use case, not in t…
adrians5j Sep 14, 2026
8f9d0a5
refactor(ai-powerups): type model roles by their real ids on the admi…
adrians5j Sep 14, 2026
3ed11f5
fix(ai-powerups): stop the capability override map claiming every key…
adrians5j Sep 14, 2026
c66a4f9
refactor(ai-powerups): name the connection preset and role assignment…
adrians5j Sep 14, 2026
9958dbf
refactor(ai-powerups): name the project preset and file types too
adrians5j Sep 14, 2026
3bf1487
test(ai-powerups): pin the upgrade path for projects already using AI
adrians5j Sep 15, 2026
24a3a86
refactor(ai-powerups): move runtime code out of the connections types…
adrians5j Sep 15, 2026
e8108a4
docs(ai-powerups): correct what the allSettled change actually rescues
adrians5j Sep 15, 2026
d7d7491
docs(ai-powerups): say which ordering in the settings init is real
adrians5j Sep 16, 2026
0a12353
docs(ai-powerups): say what the two settings loads actually fetch
adrians5j Sep 16, 2026
ff233bf
fix(ai-powerups): pass the full blob on the post-save re-read too
adrians5j Sep 16, 2026
f870d19
Merge remote-tracking branch 'origin/next' into adrian/ai-settings-ca…
adrians5j Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const prepared: IPreparedImageEnrichment = {
imageBase64: "aGVsbG8=",
imageMediaType: "image/png",
model: "anthropic/claude-sonnet-4-5",
prompt: "Describe this image.",
connection: { sdkName: "anthropic", apiKey: "key" }
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { Container } from "@webiny/di";
import CapabilitiesHandler from "~/api/features/Capabilities/CapabilitiesHandler.js";
import { AiPowerUpsSettingsGroupHandler } from "~/api/features/shared/index.js";

function buildHandler(): AiPowerUpsSettingsGroupHandler.Interface {
const container = new Container();
container.register(CapabilitiesHandler);

return container.resolve(AiPowerUpsSettingsGroupHandler);
}

const handler = buildHandler();

describe("CapabilitiesHandler", () => {
/*
* The admin form sends `null` for a field nobody has touched, and every field in an override is
* untouched by default. The first version of this schema required strings, so saving the
* settings screen without configuring a single capability failed with four copies of
* "Invalid input: expected string, received null" and no indication of which section was at
* fault.
*/
it("accepts the nulls the form sends for untouched fields", () => {
const result = handler.inputSchema.safeParse({
overrides: {
"cms.generateEntry": {
roleId: null,
connectionId: null,
model: null,
additionalInstructions: null
}
}
});

expect(result.success).toBe(true);
});

it("accepts a fully configured override", () => {
const result = handler.inputSchema.safeParse({
overrides: {
"cms.generateEntry": {
roleId: "fast",
connectionId: "conn-1",
model: "anthropic/claude-haiku-4-5",
additionalInstructions: "Be brief."
}
}
});

expect(result.success).toBe(true);
});

it("still rejects a role that does not exist", () => {
const result = handler.inputSchema.safeParse({
overrides: { "cms.generateEntry": { roleId: "cheapest" } }
});

expect(result.success).toBe(false);
});

it("drops an override where every field is empty", async () => {
const stored = await handler.mapToStorage(
{
overrides: {
"cms.generateEntry": {
roleId: null,
connectionId: null,
additionalInstructions: " "
},
"wb.generatePage": { additionalInstructions: "Keep it short." }
}
},
null
);

expect(Object.keys((stored as any).overrides)).toEqual(["wb.generatePage"]);
});

it("keeps nulls and empty strings out of a stored override", async () => {
const stored = await handler.mapToStorage(
{
overrides: {
"wb.generatePage": {
roleId: null,
connectionId: "",
model: null,
additionalInstructions: "Keep it short."
}
}
},
null
);

expect((stored as any).overrides["wb.generatePage"]).toEqual({
additionalInstructions: "Keep it short."
});
});

/*
* There is no prompt field here any more. Replacing a capability's prompt was offered and then
* removed: for most capabilities the prompt carries the output contract the surrounding code
* parses, so it is implementation rather than settings. `additionalInstructions` is the whole
* of prompt customisation now.
*/
it("rejects a prompt override, which is no longer a thing", () => {
const result = handler.inputSchema.safeParse({
overrides: { "cms.compareEntryRevisions": { guidance: "Mine now." } }
});

// Zod strips the unknown key rather than failing, so the check is that it never lands.
expect(result.success).toBe(true);
expect(result.data).toEqual({ overrides: { "cms.compareEntryRevisions": {} } });
});

it("round-trips an absent section to an empty override map", () => {
expect(handler.mapFromStorage(undefined)).toEqual({ overrides: {} });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { Container } from "@webiny/di";
import {
AiCapability,
ListAiCapabilitiesUseCase
} from "~/api/features/Capabilities/abstractions.js";
import { ListAiCapabilitiesUseCaseImplementation } from "~/api/features/Capabilities/ListAiCapabilitiesUseCase.js";

class WithGuidance implements AiCapability.Interface {
readonly id = "cms.compareEntryRevisions";
readonly label = "CMS revision comparison";
readonly description = "For tests.";
readonly defaultRole = "fast" as const;
readonly guidance = "Webiny's own prompt, which must not leave the api.";
}

class WithoutGuidance implements AiCapability.Interface {
readonly id = "cms.generateEntry";
readonly label = "CMS entry generation";
readonly description = "For tests.";
readonly defaultRole = "standard" as const;
}

function useCase(...capabilities: Array<new () => AiCapability.Interface>) {
const container = new Container();

for (const implementation of capabilities) {
container.register(AiCapability.createImplementation({ implementation, dependencies: [] }));
}
container.register(ListAiCapabilitiesUseCaseImplementation);

return container.resolve(ListAiCapabilitiesUseCase);
}

describe("ListAiCapabilitiesUseCase", () => {
it("returns every registered capability", async () => {
const result = await useCase(WithGuidance, WithoutGuidance).execute();

expect(result.map(c => c.id)).toEqual(
expect.arrayContaining(["cms.compareEntryRevisions", "cms.generateEntry"])
);
});

it("returns the fields the settings screen renders a row from", async () => {
const result = await useCase(WithoutGuidance).execute();

expect(result[0]).toEqual({
id: "cms.generateEntry",
label: "CMS entry generation",
description: "For tests.",
defaultRole: "standard"
});
});

/*
* A capability's prompt is implementation, not settings. There is no reason to ship ours to
* everyone who can read the settings screen, and the summary has no field for it.
*/
it("never exposes a capability's guidance", async () => {
const result = await useCase(WithGuidance).execute();

expect(result[0]).not.toHaveProperty("guidance");
expect(JSON.stringify(result)).not.toContain("must not leave the api");
});

it("returns an empty list when nothing is registered", async () => {
const container = new Container();
container.register(ListAiCapabilitiesUseCaseImplementation);

expect(await container.resolve(ListAiCapabilitiesUseCase).execute()).toEqual([]);
});
});
Loading
Loading