Built-in Session Template Overrides Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let users override model, engine and environment on built-in AI session templates, with a
reset-to-defaults action.
Architecture: A sparse session_template_override table stores per-built-in overrides.
SessionTemplateService merges overrides onto classpath defaults on every read, so session creation picks
them up without changes. A new REST sub-resource /assistant/templates/{templateId}/overrides (contract-first)
exposes GET/PUT/DELETE; the UI unlocks the three fields for built-ins.
Tech Stack: Java 25, Quarkus, Hibernate Panache, Flyway, JAX-RS (generated from OpenAPI), JUnit 5 + RestAssured, React + PatternFly + TypeScript.
Spec: docs/superpowers/specs/2026-10-19-builtin-template-overrides-design.md
Global Constraints
- Only
model,engine,environmentare overridable. All other built-in fields remain locked. - Null override field = use built-in default.
environmentoverride replaces (does not merge) the default. - Saving overrides with all three fields null/empty deletes the override row.
- Override rows whose
template_idis not a current built-in are ignored at read time. - Overrides are NOT part of configuration packs (
ImportExportServiceunchanged). - Existing
PUT/DELETE /assistant/templates/{templateId}keep returning 403 for built-ins. - API-first: edit
common/api/src/main/resources/openapi.jsonbefore Java; REST impl uses generated beans fromio.apitomy.axiom.api.beans; never add@Pathon impl classes. - Java style: 4-space indent, Javadoc on public methods, explicit types (no ambiguous
var), JUnit 5.
File Map
| File | Action | Responsibility |
|---|---|---|
app/src/main/resources/db/migration/V3__session_template_override.sql |
Create | Table + sequence |
core/src/main/java/io/apitomy/axiom/core/entities/SessionTemplateOverrideEntity.java |
Create | JPA entity |
app/src/main/java/io/apitomy/axiom/app/assistant/SessionTemplateService.java |
Modify | Merge + override CRUD |
app/src/test/java/io/apitomy/axiom/app/assistant/SessionTemplateOverrideServiceTest.java |
Create | Service tests |
common/api/src/main/resources/openapi.json |
Modify | New path + schema + overriddenFields |
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java |
Modify | Implement new ops |
app/src/test/java/io/apitomy/axiom/app/rest/SessionTemplateOverridesResourceTest.java |
Create | REST tests |
ui/src/config/api.ts |
Modify | Types + client functions |
ui/src/pages/SessionTemplateDetailPage.tsx |
Modify | Editable fields for built-ins, reset |
ui/src/pages/SessionTemplatesPage.tsx |
Modify | "Customized" label |
Task 1: Persistence + service merge logic
Files:
- Create: app/src/main/resources/db/migration/V3__session_template_override.sql
- Create: core/src/main/java/io/apitomy/axiom/core/entities/SessionTemplateOverrideEntity.java
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/SessionTemplateService.java
- Test: app/src/test/java/io/apitomy/axiom/app/assistant/SessionTemplateOverrideServiceTest.java
Interfaces:
- Produces:
- SessionTemplateService.SessionTemplate record gains a final component
Set<String> overriddenFields (after capabilities). Values: "model", "engine", "environment".
- public record SessionTemplateService.TemplateOverrides(String model, String engine, String environment)
— environment is a JSON object string (same format as SessionTemplate.environment), or null.
- public TemplateOverrides getOverrides(String templateId)
- public SessionTemplate setOverrides(String templateId, TemplateOverrides overrides) — returns merged
template.
- public void clearOverrides(String templateId)
- All three throw TemplateNotFoundException (new, public static class extending
RuntimeException nested in SessionTemplateService) for unknown IDs, and IllegalArgumentException
for user-defined (non-built-in) IDs.
- [ ] Step 1: Write the failing test
package io.apitomy.axiom.app.assistant;
import io.apitomy.axiom.core.entities.SessionTemplateOverrideEntity;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies built-in session template override persistence and merge behaviour.
*/
@QuarkusTest
class SessionTemplateOverrideServiceTest {
private static final String BUILT_IN = "general-assistant";
@Inject
SessionTemplateService service;
@AfterEach
@Transactional
void cleanUp() {
SessionTemplateOverrideEntity.deleteAll();
service.listTemplates().stream()
.filter(t -> !t.builtIn())
.forEach(t -> service.deleteTemplate(t.templateId()));
}
@Test
void builtInWithoutOverridesHasNoOverriddenFields() {
SessionTemplateService.SessionTemplate t = service.getTemplate(BUILT_IN);
assertTrue(t.overriddenFields().isEmpty());
assertNull(t.model());
}
@Test
void overridesAreMergedIntoGetAndList() {
service.setOverrides(BUILT_IN, new SessionTemplateService.TemplateOverrides(
"my-model", "opencode", "{\"FOO\":\"bar\"}"));
SessionTemplateService.SessionTemplate t = service.getTemplate(BUILT_IN);
assertEquals("my-model", t.model());
assertEquals("opencode", t.engine());
assertEquals("{\"FOO\":\"bar\"}", t.environment());
assertEquals(Set.of("model", "engine", "environment"), t.overriddenFields());
assertTrue(t.builtIn());
assertTrue(t.systemPrompt().length() > 0);
SessionTemplateService.SessionTemplate listed = service.listTemplates().stream()
.filter(x -> x.templateId().equals(BUILT_IN)).findFirst().orElseThrow();
assertEquals("my-model", listed.model());
}
@Test
void partialOverrideOnlyReportsSetFields() {
service.setOverrides(BUILT_IN, new SessionTemplateService.TemplateOverrides("m", null, null));
SessionTemplateService.SessionTemplate t = service.getTemplate(BUILT_IN);
assertEquals(Set.of("model"), t.overriddenFields());
assertNull(t.engine());
}
@Test
void getOverridesReturnsStoredValuesOrEmpty() {
SessionTemplateService.TemplateOverrides empty = service.getOverrides(BUILT_IN);
assertNull(empty.model());
service.setOverrides(BUILT_IN, new SessionTemplateService.TemplateOverrides("m", "e", null));
SessionTemplateService.TemplateOverrides o = service.getOverrides(BUILT_IN);
assertEquals("m", o.model());
assertEquals("e", o.engine());
}
@Test
void emptySaveDeletesRow() {
service.setOverrides(BUILT_IN, new SessionTemplateService.TemplateOverrides("m", null, null));
service.setOverrides(BUILT_IN, new SessionTemplateService.TemplateOverrides(" ", null, "{}"));
assertEquals(0L, SessionTemplateOverrideEntity.count());
assertTrue(service.getTemplate(BUILT_IN).overriddenFields().isEmpty());
}
@Test
void clearOverridesRestoresDefaults() {
service.setOverrides(BUILT_IN, new SessionTemplateService.TemplateOverrides("m", null, null));
service.clearOverrides(BUILT_IN);
assertNull(service.getTemplate(BUILT_IN).model());
assertEquals(0L, SessionTemplateOverrideEntity.count());
}
@Test
void unknownTemplateIsRejected() {
assertThrows(SessionTemplateService.TemplateNotFoundException.class,
() -> service.getOverrides("nope"));
assertThrows(SessionTemplateService.TemplateNotFoundException.class,
() -> service.setOverrides("nope",
new SessionTemplateService.TemplateOverrides("m", null, null)));
assertThrows(SessionTemplateService.TemplateNotFoundException.class,
() -> service.clearOverrides("nope"));
}
@Test
void userTemplateIsRejected() {
SessionTemplateService.SessionTemplate user = service.createTemplate(
new SessionTemplateService.SessionTemplate(null, "u", "d", "p", null, null, null,
null, null, null, null, null, List.of(), List.of(), false, Set.of(), Set.of()));
assertThrows(IllegalArgumentException.class,
() -> service.setOverrides(user.templateId(),
new SessionTemplateService.TemplateOverrides("m", null, null)));
assertThrows(IllegalArgumentException.class, () -> service.getOverrides(user.templateId()));
assertThrows(IllegalArgumentException.class, () -> service.clearOverrides(user.templateId()));
}
@Test
@Transactional
void orphanedOverrideRowIsIgnored() {
SessionTemplateOverrideEntity orphan = new SessionTemplateOverrideEntity();
orphan.templateId = "removed-built-in";
orphan.model = "m";
orphan.persist();
assertNull(service.getTemplate("removed-built-in"));
assertTrue(service.listTemplates().stream()
.noneMatch(t -> t.templateId().equals("removed-built-in")));
}
}
- [ ] Step 2: Run test to verify it fails
Run: mvn -q -pl app -am test -Dtest=SessionTemplateOverrideServiceTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: compilation failure (SessionTemplateOverrideEntity, TemplateOverrides, overriddenFields not
defined).
- [ ] Step 3: Create the migration
app/src/main/resources/db/migration/V3__session_template_override.sql:
-- Axiom 3.x incremental migration 3.
--
-- Per-deployment overrides for built-in AI session templates. Built-in templates themselves are loaded from
-- the classpath; only the overridable fields (model, engine, environment) are stored here. A null column
-- means "use the built-in default".
CREATE SEQUENCE session_template_override_SEQ START WITH 1 INCREMENT BY 50;
CREATE TABLE session_template_override (
id BIGINT NOT NULL,
template_id VARCHAR(255) NOT NULL,
model VARCHAR(255),
engine VARCHAR(255),
environment TEXT,
PRIMARY KEY (id),
CONSTRAINT uk_session_template_override_template_id UNIQUE (template_id)
);
- [ ] Step 4: Create the entity
core/src/main/java/io/apitomy/axiom/core/entities/SessionTemplateOverrideEntity.java:
package io.apitomy.axiom.core.entities;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
/**
* Stores user overrides for a built-in AI Assistant session template. Only the
* overridable fields are persisted; a null value means "use the built-in default".
*/
@Entity
@Table(name = "session_template_override")
public class SessionTemplateOverrideEntity extends PanacheEntity {
/** The built-in template identifier these overrides apply to. */
@Column(name = "template_id", nullable = false, unique = true)
public String templateId;
/** Optional AI model override. */
@Column(name = "model")
public String model;
/** Optional interactive engine override. */
@Column(name = "engine")
public String engine;
/** Optional JSON object of environment variables; replaces the built-in default. */
@Column(columnDefinition = "TEXT")
public String environment;
/**
* Finds the override row for a template.
*
* @param templateId the built-in template identifier
* @return the override entity, or null if none exists
*/
public static SessionTemplateOverrideEntity findByTemplateId(String templateId) {
return find("templateId", templateId).firstResult();
}
}
- [ ] Step 5: Update
SessionTemplateService
5a. Add Set<String> overriddenFields as the final record component of SessionTemplate and document it
in the record Javadoc (@param overriddenFields names of fields overridden by the user (built-ins only)).
Update every constructor call: in toTemplate(...) and parseTemplateJson(...) append Set.of() as the
last argument. Also update AssistantResourceImpl.fromTemplateBean (append java.util.Set.of()) so the
app compiles; grep for other callers with grep -rn "new SessionTemplateService.SessionTemplate\|new SessionTemplate(" app/src
and append Set.of() to each. Known callers: SessionTemplateService.java (2), AssistantResourceImpl.java
(fromTemplateBean), and app/src/test/.../assistant/AssistantToolsetResolutionTest.java:42 — include the
test file in the Step 8 commit.
5b. Add imports io.apitomy.axiom.core.entities.SessionTemplateOverrideEntity, java.util.LinkedHashSet,
java.util.stream.Collectors.
5c. Add nested types after the SessionTemplate record:
/**
* User overrides for a built-in template. A null field means "use the built-in default".
*
* @param model AI model override, or null
* @param engine interactive engine override, or null
* @param environment JSON object string of environment variables, or null
*/
public record TemplateOverrides(String model, String engine, String environment) {
}
/**
* Thrown when a template identifier does not refer to any known template.
*/
public static class TemplateNotFoundException extends RuntimeException {
/**
* Creates the exception.
*
* @param templateId the unknown template identifier
*/
public TemplateNotFoundException(String templateId) {
super("Template not found: " + templateId);
}
}
5d. Replace listTemplates() and getTemplate(...):
public List<SessionTemplate> listTemplates() {
Map<String, SessionTemplateOverrideEntity> overrides =
SessionTemplateOverrideEntity.<SessionTemplateOverrideEntity>listAll().stream()
.collect(Collectors.toMap(o -> o.templateId, o -> o));
List<SessionTemplate> result = new ArrayList<>();
for (SessionTemplate builtIn : builtInTemplates.values()) {
result.add(applyOverrides(builtIn, overrides.get(builtIn.templateId())));
}
List<SessionTemplateEntity> entities = SessionTemplateEntity.listAll();
for (SessionTemplateEntity entity : entities) {
result.add(toTemplate(entity));
}
return result;
}
public SessionTemplate getTemplate(String templateId) {
SessionTemplate builtIn = builtInTemplates.get(templateId);
if (builtIn != null) {
return applyOverrides(builtIn, SessionTemplateOverrideEntity.findByTemplateId(templateId));
}
SessionTemplateEntity entity = SessionTemplateEntity
.find("templateId", templateId).firstResult();
return entity != null ? toTemplate(entity) : null;
}
(Keep the existing Javadoc on both; add "Built-in templates are returned with user overrides applied." to each.)
5e. Add the override API methods (after deleteTemplate):
/**
* Returns the stored overrides for a built-in template.
*
* @param templateId the built-in template identifier
* @return the overrides; all fields null when none are stored
* @throws TemplateNotFoundException if the template does not exist
* @throws IllegalArgumentException if the template is not built-in
*/
public TemplateOverrides getOverrides(String templateId) {
requireBuiltIn(templateId);
SessionTemplateOverrideEntity entity = SessionTemplateOverrideEntity.findByTemplateId(templateId);
if (entity == null) {
return new TemplateOverrides(null, null, null);
}
return new TemplateOverrides(entity.model, entity.engine, entity.environment);
}
/**
* Replaces the overrides for a built-in template. Blank values are treated as null; when
* every field is null the stored overrides are removed.
*
* @param templateId the built-in template identifier
* @param overrides the new overrides
* @return the built-in template with overrides applied
* @throws TemplateNotFoundException if the template does not exist
* @throws IllegalArgumentException if the template is not built-in
*/
@Transactional
public SessionTemplate setOverrides(String templateId, TemplateOverrides overrides) {
requireBuiltIn(templateId);
String model = blankToNull(overrides.model());
String engine = blankToNull(overrides.engine());
String environment = normalizeEnvironment(overrides.environment());
SessionTemplateOverrideEntity entity = SessionTemplateOverrideEntity.findByTemplateId(templateId);
if (model == null && engine == null && environment == null) {
if (entity != null) {
entity.delete();
}
return builtInTemplates.get(templateId);
}
if (entity == null) {
entity = new SessionTemplateOverrideEntity();
entity.templateId = templateId;
}
entity.model = model;
entity.engine = engine;
entity.environment = environment;
entity.persist();
return applyOverrides(builtInTemplates.get(templateId), entity);
}
/**
* Removes all overrides for a built-in template, restoring its defaults.
*
* @param templateId the built-in template identifier
* @throws TemplateNotFoundException if the template does not exist
* @throws IllegalArgumentException if the template is not built-in
*/
@Transactional
public void clearOverrides(String templateId) {
requireBuiltIn(templateId);
SessionTemplateOverrideEntity.delete("templateId", templateId);
}
private void requireBuiltIn(String templateId) {
if (isBuiltIn(templateId)) {
return;
}
if (SessionTemplateEntity.find("templateId", templateId).firstResult() != null) {
throw new IllegalArgumentException(
"Overrides are only supported for built-in templates: " + templateId);
}
throw new TemplateNotFoundException(templateId);
}
private SessionTemplate applyOverrides(SessionTemplate base, SessionTemplateOverrideEntity o) {
if (o == null) {
return base;
}
Set<String> overridden = new LinkedHashSet<>();
String model = base.model();
String engine = base.engine();
String environment = base.environment();
if (o.model != null) {
model = o.model;
overridden.add("model");
}
if (o.engine != null) {
engine = o.engine;
overridden.add("engine");
}
if (o.environment != null) {
environment = o.environment;
overridden.add("environment");
}
return new SessionTemplate(base.templateId(), base.name(), base.description(), base.systemPrompt(),
base.welcomeMessage(), base.initialMessage(), base.workingDirectory(), model, engine,
base.initScript(), base.initScriptType(), environment, base.mcpServers(),
base.allowedTools(), base.builtIn(), base.capabilities(), Set.copyOf(overridden));
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String normalizeEnvironment(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
JsonNode node = objectMapper.readTree(json);
return node.isObject() && node.size() > 0 ? node.toString() : null;
} catch (IOException e) {
throw new IllegalArgumentException("Invalid environment JSON", e);
}
}
- [ ] Step 6: Run test to verify it passes
Run: mvn -q -pl app -am test -Dtest=SessionTemplateOverrideServiceTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: all 9 tests PASS. (Note: {"FOO":"bar"} round-trips unchanged through normalizeEnvironment.)
- [ ] Step 7: Run existing assistant tests for regressions
Run: mvn -q -pl app -am test -Dtest='io.apitomy.axiom.app.assistant.*Test' -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS.
- [ ] Step 8: Commit
git add app/src/main/resources/db/migration/V3__session_template_override.sql \
core/src/main/java/io/apitomy/axiom/core/entities/SessionTemplateOverrideEntity.java \
app/src/main/java/io/apitomy/axiom/app/assistant/SessionTemplateService.java \
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java \
app/src/test/java/io/apitomy/axiom/app/assistant/SessionTemplateOverrideServiceTest.java \
app/src/test/java/io/apitomy/axiom/app/assistant/AssistantToolsetResolutionTest.java
git commit -m "feat(assistant): persist and merge overrides for built-in session templates"
Task 2: REST API (contract-first)
Files:
- Modify: common/api/src/main/resources/openapi.json
- Modify: app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java
- Test: app/src/test/java/io/apitomy/axiom/app/rest/SessionTemplateOverridesResourceTest.java
Interfaces:
- Consumes (Task 1): SessionTemplateService.getOverrides/setOverrides/clearOverrides,
TemplateOverrides(String model, String engine, String environment), TemplateNotFoundException,
SessionTemplate.overriddenFields().
- Produces (for Task 3):
- GET /api/v1/assistant/templates/{templateId}/overrides → {model?, engine?, environment?}
- PUT same path, body {model?, engine?, environment?} → merged SessionTemplate
- DELETE same path → 204
- SessionTemplate.overriddenFields: string[] on all template responses.
- Generated bean io.apitomy.axiom.api.beans.SessionTemplateOverrides and interface methods
getSessionTemplateOverrides(String), updateSessionTemplateOverrides(String, SessionTemplateOverrides),
deleteSessionTemplateOverrides(String) on the generated Assistant resource interface.
- [ ] Step 1: Write the failing test
package io.apitomy.axiom.app.rest;
import io.apitomy.axiom.app.assistant.AssistantSession;
import io.apitomy.axiom.app.assistant.runtime.InteractiveSessionDriver;
import io.apitomy.axiom.app.assistant.runtime.InteractiveSessionDriverFactory;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* REST tests for the built-in session template overrides sub-resource.
*/
@QuarkusTest
class SessionTemplateOverridesResourceTest {
private static final String BASE = "/api/v1/assistant/templates";
private static final String BUILT_IN = "general-assistant";
@InjectMock
InteractiveSessionDriverFactory interactiveSessionDriverFactory;
@AfterEach
void cleanUp() {
given().delete(BASE + "/{id}/overrides", BUILT_IN);
}
@Test
void getReturnsEmptyOverridesByDefault() {
given().get(BASE + "/{id}/overrides", BUILT_IN)
.then().statusCode(200)
.body("model", nullValue())
.body("engine", nullValue())
.body("environment", nullValue());
given().get(BASE + "/{id}", BUILT_IN)
.then().statusCode(200).body("overriddenFields", empty());
}
@Test
void putAppliesOverridesAndReportsOverriddenFields() {
given().contentType(ContentType.JSON)
.body("{\"model\":\"m1\",\"environment\":{\"FOO\":\"bar\"}}")
.put(BASE + "/{id}/overrides", BUILT_IN)
.then().statusCode(200)
.body("model", equalTo("m1"))
.body("environment.FOO", equalTo("bar"))
.body("builtIn", equalTo(true))
.body("overriddenFields", containsInAnyOrder("model", "environment"));
given().get(BASE + "/{id}/overrides", BUILT_IN)
.then().statusCode(200).body("model", equalTo("m1"));
given().get(BASE)
.then().statusCode(200)
.body("find { it.templateId == 'general-assistant' }.overriddenFields", hasItem("model"));
}
@Test
void deleteResetsToDefaults() {
given().contentType(ContentType.JSON).body("{\"model\":\"m1\"}")
.put(BASE + "/{id}/overrides", BUILT_IN).then().statusCode(200);
given().delete(BASE + "/{id}/overrides", BUILT_IN).then().statusCode(204);
given().get(BASE + "/{id}", BUILT_IN)
.then().statusCode(200)
.body("model", nullValue())
.body("overriddenFields", empty());
}
@Test
void unknownTemplateReturns404() {
given().get(BASE + "/{id}/overrides", "nope").then().statusCode(404);
given().contentType(ContentType.JSON).body("{\"model\":\"m\"}")
.put(BASE + "/{id}/overrides", "nope").then().statusCode(404);
given().delete(BASE + "/{id}/overrides", "nope").then().statusCode(404);
}
@Test
void userTemplateReturns400() {
String id = given().contentType(ContentType.JSON)
.body("{\"name\":\"u\",\"description\":\"d\",\"systemPrompt\":\"p\"}")
.post(BASE).then().statusCode(201).extract().path("templateId");
try {
given().get(BASE + "/{id}/overrides", id).then().statusCode(400);
given().contentType(ContentType.JSON).body("{\"model\":\"m\"}")
.put(BASE + "/{id}/overrides", id).then().statusCode(400);
given().delete(BASE + "/{id}/overrides", id).then().statusCode(400);
} finally {
given().delete(BASE + "/{id}", id);
}
}
@Test
void existingBuiltInPutStillForbidden() {
given().contentType(ContentType.JSON)
.body("{\"name\":\"x\",\"description\":\"d\",\"systemPrompt\":\"p\"}")
.put(BASE + "/{id}", BUILT_IN).then().statusCode(403);
}
@Test
void sessionCreationUsesOverriddenEngine() throws IOException {
String engine = given().get("/api/v1/system/config").then().statusCode(200)
.extract().path("engines.find { it.supportsInteractiveSessions }.type");
given().contentType(ContentType.JSON).body("{\"engine\":\"" + engine + "\"}")
.put(BASE + "/{id}/overrides", BUILT_IN).then().statusCode(200);
when(interactiveSessionDriverFactory.createDriver(any())).thenAnswer(i -> {
InteractiveSessionDriver driver = mock(InteractiveSessionDriver.class);
when(driver.getStatus()).thenReturn(AssistantSession.Status.RUNNING);
return driver;
});
String sessionId = given().contentType(ContentType.JSON)
.body("{\"templateId\":\"" + BUILT_IN + "\",\"name\":\"override-test\"}")
.post("/api/v1/assistant/sessions")
.then().statusCode(200).extract().path("id");
try {
ArgumentCaptor<InteractiveSessionDriverFactory.DriverRequest> captor =
ArgumentCaptor.forClass(InteractiveSessionDriverFactory.DriverRequest.class);
verify(interactiveSessionDriverFactory, atLeastOnce()).createDriver(captor.capture());
assertEquals(engine, captor.getValue().engineType());
} finally {
given().delete("/api/v1/assistant/sessions/{id}", sessionId);
}
}
}
Note: confirm the system config path by checking fetchSystemConfig in ui/src/config/api.ts; adjust
/api/v1/system/config if it differs.
- [ ] Step 2: Run test to verify it fails
Run: mvn -q -pl app -am test -Dtest=SessionTemplateOverridesResourceTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: FAIL — 404/405 on /overrides, missing overriddenFields.
- [ ] Step 3: Update
openapi.json
Prefer the apicurio-data-models MCP tools for these edits if available; otherwise edit JSON directly.
3a. Add new path immediately after the /assistant/templates/{templateId} path object:
"/assistant/templates/{templateId}/overrides": {
"get": {
"tags": ["Assistant"],
"summary": "Get user overrides for a built-in session template",
"operationId": "getSessionTemplateOverrides",
"responses": {
"200": {
"description": "Current overrides (fields are absent when not overridden)",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionTemplateOverrides" }
}
}
},
"400": { "description": "Template is not a built-in template" },
"404": { "description": "Template not found" }
}
},
"put": {
"tags": ["Assistant"],
"summary": "Replace user overrides for a built-in session template",
"operationId": "updateSessionTemplateOverrides",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionTemplateOverrides" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Template with overrides applied",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionTemplate" }
}
}
},
"400": { "description": "Template is not a built-in template" },
"404": { "description": "Template not found" }
}
},
"delete": {
"tags": ["Assistant"],
"summary": "Remove user overrides, restoring built-in defaults",
"operationId": "deleteSessionTemplateOverrides",
"responses": {
"204": { "description": "Overrides removed" },
"400": { "description": "Template is not a built-in template" },
"404": { "description": "Template not found" }
}
},
"parameters": [
{
"name": "templateId",
"in": "path",
"description": "The built-in template identifier",
"required": true,
"schema": { "type": "string" }
}
]
},
3b. Add to SessionTemplate.properties:
"overriddenFields": {
"description": "Names of fields (model, engine, environment) overridden by the user on a built-in template. Empty for user-defined templates.",
"type": "array",
"items": { "type": "string" },
"readOnly": true
},
3c. Add new schema next to NewSessionTemplate:
"SessionTemplateOverrides": {
"description": "User-editable overrides for a built-in session template. Absent or null fields use the built-in default.",
"type": "object",
"properties": {
"model": {
"description": "AI model override",
"type": "string"
},
"engine": {
"description": "Interactive engine override",
"type": "string"
},
"environment": {
"description": "Environment variables; replaces the built-in default. Values can reference secrets using ${secret:NAME} syntax.",
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
- [ ] Step 4: Regenerate interfaces/beans
Run: mvn -q -pl common/api install -DskipTests
Expected: BUILD SUCCESS. Then confirm the generated names:
grep -rn "SessionTemplateOverrides" common/api/target/generated-sources/jaxrs | head
Note the generated environment type for SessionTemplateOverrides.environment (likely a nested class,
e.g. io.apitomy.axiom.api.beans.Environment__1 or similar) — use the actual generated class name in Step 5.
- [ ] Step 5: Implement in
AssistantResourceImpl
5a. In toTemplateBean, add:
5b. Add after deleteSessionTemplate (import io.apitomy.axiom.api.beans.SessionTemplateOverrides):
/** {@inheritDoc} */
@Override
public SessionTemplateOverrides getSessionTemplateOverrides(String templateId) {
try {
SessionTemplateService.TemplateOverrides o = templateService.getOverrides(templateId);
SessionTemplateOverrides bean = new SessionTemplateOverrides();
bean.setModel(o.model());
bean.setEngine(o.engine());
bean.setEnvironment(toOverridesEnvironment(o.environment()));
return bean;
} catch (SessionTemplateService.TemplateNotFoundException e) {
throw new WebApplicationException(e.getMessage(), 404);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(), 400);
}
}
/** {@inheritDoc} */
@Override
public SessionTemplate updateSessionTemplateOverrides(String templateId,
SessionTemplateOverrides data) {
try {
String env = data.getEnvironment() == null ? null
: objectMapper.writeValueAsString(data.getEnvironment().getAdditionalProperties());
SessionTemplateService.SessionTemplate updated = templateService.setOverrides(templateId,
new SessionTemplateService.TemplateOverrides(data.getModel(), data.getEngine(), env));
return toTemplateBean(updated);
} catch (SessionTemplateService.TemplateNotFoundException e) {
throw new WebApplicationException(e.getMessage(), 404);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(), 400);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new WebApplicationException("Invalid environment: " + e.getMessage(), 400);
}
}
/** {@inheritDoc} */
@Override
public void deleteSessionTemplateOverrides(String templateId) {
try {
templateService.clearOverrides(templateId);
} catch (SessionTemplateService.TemplateNotFoundException e) {
throw new WebApplicationException(e.getMessage(), 404);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(), 400);
}
}
5c. Add helper toOverridesEnvironment(String json) mirroring the existing jsonToEnvironment but returning
the generated environment type of SessionTemplateOverrides (name found in Step 4). If the generator reuses
the Environment class, delete this helper and call jsonToEnvironment directly.
- [ ] Step 6: Run tests
Run: mvn -q -pl app -am test -Dtest='SessionTemplateOverridesResourceTest,SessionTemplateOverrideServiceTest,SessionCapabilityEndpointTest' -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS.
- [ ] Step 7: Commit
git add common/api/src/main/resources/openapi.json \
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java \
app/src/test/java/io/apitomy/axiom/app/rest/SessionTemplateOverridesResourceTest.java
git commit -m "feat(api): add overrides sub-resource for built-in session templates"
Task 3: UI
Files:
- Modify: ui/src/config/api.ts
- Modify: ui/src/pages/SessionTemplateDetailPage.tsx
- Modify: ui/src/pages/SessionTemplatesPage.tsx
Interfaces:
- Consumes (Task 2): REST endpoints and overriddenFields.
- Produces: SessionTemplateOverrides TS type; updateAssistantTemplateOverrides,
deleteAssistantTemplateOverrides functions.
- [ ] Step 1: API client (
ui/src/config/api.ts)
Add overriddenFields?: string[]; to interface SessionTemplate. After NewSessionTemplate add:
export interface SessionTemplateOverrides {
model?: string;
engine?: string;
environment?: Record<string, string>;
}
After updateAssistantTemplate add:
export async function updateAssistantTemplateOverrides(
templateId: string,
data: SessionTemplateOverrides
): Promise<SessionTemplate> {
const response = await fetch(
`${API}/assistant/templates/${encodeURIComponent(templateId)}/overrides`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
if (!response.ok) throw new Error("Failed to update template overrides");
return response.json();
}
export async function deleteAssistantTemplateOverrides(templateId: string): Promise<void> {
const response = await fetch(
`${API}/assistant/templates/${encodeURIComponent(templateId)}/overrides`,
{ method: "DELETE" }
);
if (!response.ok) throw new Error("Failed to reset template overrides");
}
- [ ] Step 2: Detail page (
SessionTemplateDetailPage.tsx)
2a. Import updateAssistantTemplateOverrides, deleteAssistantTemplateOverrides from ../config/api,
and Modal, ModalVariant, ModalHeader, ModalBody, ModalFooter from @patternfly/react-core
(match whatever Modal import style SessionTemplatesPage.tsx uses for its delete confirm — copy it).
2b. Add state: const [confirmReset, setConfirmReset] = useState(false);
2c. Replace handleSave body's save call so built-ins use the overrides endpoint:
const envToSend = Object.keys(envVars).length > 0 ? envVars : undefined;
const save = template?.builtIn
? updateAssistantTemplateOverrides(templateId, {
model: form.model, engine: form.engine, environment: envToSend,
})
: updateAssistantTemplate(templateId, { ...form, environment: envToSend });
save
.then((updated) => {
setTemplate(updated);
setDirty(false);
})
.catch(console.error)
.finally(() => setSaving(false));
2d. Add reset handler:
const handleReset = () => {
if (!templateId) return;
setConfirmReset(false);
deleteAssistantTemplateOverrides(templateId)
.then(() => loadData())
.catch(console.error);
};
2e. After const isReadOnly = template.builtIn; add:
const overridden = new Set(template.overriddenFields || []);
const modifiedLabel = (field: string) => overridden.has(field)
? <Label color="orange" isCompact style={{ marginLeft: 8 }}>Modified</Label>
: null;
2f. Header: change the built-in label text from Built-in (read-only) to Built-in. Replace the
{!isReadOnly && (<FlexItem>…Save…</FlexItem>)} block with:
{isReadOnly && overridden.size > 0 && (
<FlexItem>
<Button variant="secondary" onClick={() => setConfirmReset(true)}>
Reset to defaults
</Button>
</FlexItem>
)}
<FlexItem>
<Button variant="primary" onClick={handleSave}
isDisabled={!dirty || !form.name || saving}
isLoading={saving}>
Save
</Button>
</FlexItem>
2g. Below the header Flex, for built-ins, add:
{isReadOnly && (
<Alert variant="info" isInline style={{ marginBottom: 16 }}
title="Built-in template: only Engine, Model and Environment can be customized." />
)}
2h. Engine FormGroup: change label="Engine" to label={<>Engine{modifiedLabel("engine")}</>} and
remove isDisabled={isReadOnly} from its FormSelect.
Model FormGroup: label={<>Model{modifiedLabel("model")}</>} and remove isDisabled={isReadOnly} from its
FormSelect.
2i. Environment tab: change title to
<TabTitleText>Environment{overridden.has("environment") ? " *" : ""}</TabTitleText> and replace the
isReadOnly ? (<p>…</p>) : (<EnvironmentTab …/>) conditional with just:
{modifiedLabel("environment")}
<EnvironmentTab
envVars={envVars}
onChange={(updated) => {
setEnvVars(updated);
setDirty(true);
}}
/>
2j. Before the closing </PageSection>, add the confirm modal:
<Modal variant={ModalVariant.small} isOpen={confirmReset}
onClose={() => setConfirmReset(false)}>
<ModalHeader title="Reset to defaults?" />
<ModalBody>
This removes your Engine, Model and Environment customizations for
"{template.name}".
</ModalBody>
<ModalFooter>
<Button variant="danger" onClick={handleReset}>Reset</Button>
<Button variant="link" onClick={() => setConfirmReset(false)}>Cancel</Button>
</ModalFooter>
</Modal>
- [ ] Step 3: List page (
SessionTemplatesPage.tsx)
Replace the type-column <Td> contents with:
<Label color={t.builtIn ? "blue" : "green"}>
{t.builtIn ? "Built-in" : "Custom"}
</Label>
{t.builtIn && (t.overriddenFields?.length ?? 0) > 0 && (
<Label color="orange" style={{ marginLeft: 8 }}>
Customized
</Label>
)}
- [ ] Step 4: Type-check, lint, unit tests
Run (workdir ui): npm run build && npm test
Expected: build succeeds, tests pass.
- [ ] Step 5: Manual browser verification
Start the app (dev mode per project README), open /session-templates/general-assistant via Chrome DevTools
MCP, and verify:
1. Info alert shown; Name/Description/System Prompt still read-only; Engine/Model/Environment editable.
2. Change Model → Save → "Modified" label appears next to Model; list page shows "Customized".
3. Add env var → Save → Environment tab title shows *.
4. "Reset to defaults" → confirm → fields revert, labels disappear.
5. A custom (non-built-in) template still saves normally via the original endpoint.
- [ ] Step 6: Commit
git add ui/src/config/api.ts ui/src/pages/SessionTemplateDetailPage.tsx ui/src/pages/SessionTemplatesPage.tsx
git commit -m "feat(ui): allow customizing engine, model and environment on built-in templates"
Task 4: Full build verification
- [ ] Step 1: Run
./build.sh. Expected: BUILD SUCCESS, all tests pass. - [ ] Step 2: If any docs under
docs/describe built-in templates as fully read-only (grep -rln "read-only" docs | xargs grep -l -i "template"), update the wording to describe the Engine/Model/Environment customization and reset; wrap markdown at 110 chars. Commit:git commit -am "docs: describe built-in template customization".